diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs
index 7d863a19..89fa9f05 100644
--- a/src/OneWare.Copilot/CopilotModule.cs
+++ b/src/OneWare.Copilot/CopilotModule.cs
@@ -41,8 +41,7 @@ public class CopilotModule : OneWareModuleBase
Name = "Copilot CLI",
Description = "Used for Copilot Integration",
License = "GitHub Copilot CLI License",
- IconUrl =
- "https://raw.githubusercontent.com/lobehub/lobe-icons/refs/heads/master/packages/static-png/dark/githubcopilot.png",
+ IconUrl = "https://github.githubassets.com/images/modules/site/copilot/copilot.png",
AcceptLicenseBeforeDownload = true,
Links =
[
diff --git a/src/OneWare.PackageManager/ViewModels/FeaturedPackageViewModel.cs b/src/OneWare.PackageManager/ViewModels/FeaturedPackageViewModel.cs
new file mode 100644
index 00000000..5e48c34f
--- /dev/null
+++ b/src/OneWare.PackageManager/ViewModels/FeaturedPackageViewModel.cs
@@ -0,0 +1,95 @@
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using OneWare.Essentials.Enums;
+
+namespace OneWare.PackageManager.ViewModels;
+
+///
+/// Backs the hero banner above the package list. It promotes a single hardcoded package and is not
+/// part of the list itself, so it can never move or reorder any package entry.
+///
+public class FeaturedPackageViewModel : ObservableObject, IDisposable
+{
+ private PackageViewModel? _target;
+
+ public FeaturedPackageViewModel(string title, string description, string iconResourceKey, string learnMoreUrl,
+ ICommand showDetailsCommand)
+ {
+ Title = title;
+ Description = description;
+ IconResourceKey = iconResourceKey;
+ LearnMoreUrl = learnMoreUrl;
+ ShowDetailsCommand = showDetailsCommand;
+ PrimaryCommand = new AsyncRelayCommand(ExecutePrimaryAsync);
+ }
+
+ public string Title { get; }
+
+ public string Description { get; }
+
+ public string IconResourceKey { get; }
+
+ public string LearnMoreUrl { get; }
+
+ public ICommand ShowDetailsCommand { get; }
+
+ ///
+ /// Runs the action of the promoted package. Unlike the list row, the banner does not select the
+ /// package, so the tabs the install flow depends on have to be resolved explicitly.
+ ///
+ public AsyncRelayCommand PrimaryCommand { get; }
+
+ ///
+ /// The view model of the promoted package, or null while the package is not part of the catalog.
+ ///
+ public PackageViewModel? Target
+ {
+ get => _target;
+ set
+ {
+ if (ReferenceEquals(_target, value)) return;
+
+ if (_target != null) _target.StatusChanged -= OnTargetStatusChanged;
+
+ _target = value;
+
+ if (_target != null) _target.StatusChanged += OnTargetStatusChanged;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsVisible));
+ }
+ }
+
+ ///
+ /// The banner is only shown while the package is not on disk yet.
+ /// keeps it visible so it does not disappear underneath the cancel button during a download.
+ ///
+ public bool IsVisible => _target?.PackageState.Status is PackageStatus.Available
+ or PackageStatus.Installing
+ or PackageStatus.UpdateAvailable
+ or PackageStatus.UpdateAvailablePrerelease;
+
+ public void Dispose()
+ {
+ if (_target != null) _target.StatusChanged -= OnTargetStatusChanged;
+ _target = null;
+ }
+
+ private void OnTargetStatusChanged(object? sender, EventArgs e)
+ {
+ OnPropertyChanged(nameof(IsVisible));
+ }
+
+ private async Task ExecutePrimaryAsync()
+ {
+ if (_target is not { } target) return;
+
+ // Packages with AcceptLicenseBeforeDownload abort silently when the license tab is missing.
+ if (!target.IsTabsResolved) await target.ResolveTabsAsync();
+
+ // The status may have changed while the tabs were resolved, so the command is read again.
+ if (target.MainButtonCommand is { } command && command.CanExecute(null))
+ command.Execute(null);
+ }
+}
diff --git a/src/OneWare.PackageManager/ViewModels/ObservableCollectionReconciler.cs b/src/OneWare.PackageManager/ViewModels/ObservableCollectionReconciler.cs
new file mode 100644
index 00000000..64eca63c
--- /dev/null
+++ b/src/OneWare.PackageManager/ViewModels/ObservableCollectionReconciler.cs
@@ -0,0 +1,69 @@
+using System.Collections.ObjectModel;
+
+namespace OneWare.PackageManager.ViewModels;
+
+public static class ObservableCollectionReconciler
+{
+ ///
+ /// Brings in line with using the fewest
+ /// notifications.
+ ///
+ /// As long as the relative order of the surviving items is unchanged, only removals and
+ /// insertions are emitted, which keeps a bound list box's selection and scroll position
+ /// intact. A genuine reorder falls back to a reset, which is acceptable because it can only
+ /// result from a user triggered relayout.
+ ///
+ ///
+ ///
+ /// Items are matched by the default comparer. The package view models do not override Equals, so
+ /// that is reference equality. A duplicate in cannot be matched
+ /// unambiguously and falls back to a reset.
+ ///
+ public static void Reconcile(ObservableCollection target, IReadOnlyList desired) where T : notnull
+ {
+ var desiredIndex = new Dictionary(desired.Count);
+ for (var i = 0; i < desired.Count; i++)
+ desiredIndex[desired[i]] = i;
+
+ if (desiredIndex.Count != desired.Count)
+ {
+ Reset(target, desired);
+ return;
+ }
+
+ for (var i = target.Count - 1; i >= 0; i--)
+ if (!desiredIndex.ContainsKey(target[i]))
+ target.RemoveAt(i);
+
+ var ordered = true;
+ var previous = -1;
+
+ foreach (var item in target)
+ {
+ var index = desiredIndex[item];
+ if (index < previous)
+ {
+ ordered = false;
+ break;
+ }
+
+ previous = index;
+ }
+
+ if (!ordered)
+ {
+ Reset(target, desired);
+ return;
+ }
+
+ for (var i = 0; i < desired.Count; i++)
+ if (i >= target.Count || !EqualityComparer.Default.Equals(target[i], desired[i]))
+ target.Insert(i, desired[i]);
+ }
+
+ private static void Reset(ObservableCollection target, IReadOnlyList desired)
+ {
+ target.Clear();
+ foreach (var item in desired) target.Add(item);
+ }
+}
diff --git a/src/OneWare.PackageManager/ViewModels/PackageCategoryKind.cs b/src/OneWare.PackageManager/ViewModels/PackageCategoryKind.cs
new file mode 100644
index 00000000..8e5697fa
--- /dev/null
+++ b/src/OneWare.PackageManager/ViewModels/PackageCategoryKind.cs
@@ -0,0 +1,16 @@
+namespace OneWare.PackageManager.ViewModels;
+
+public enum PackageCategoryKind
+{
+ Normal,
+
+ ///
+ /// The single tree root aggregating every package. Never matched by the search, never hidden.
+ ///
+ Root,
+
+ ///
+ /// Smart category listing the packages that have an update available.
+ ///
+ Updates
+}
diff --git a/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs
index 6205f3eb..3399773d 100644
--- a/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs
+++ b/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs
@@ -1,20 +1,80 @@
-using System.Collections.ObjectModel;
+using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
-using DynamicData;
using OneWare.Essentials.Enums;
-using OneWare.Essentials.Models;
namespace OneWare.PackageManager.ViewModels;
-public class PackageCategoryViewModel(string header, IconModel? iconModel = null) : ObservableObject
+public class PackageCategoryViewModel : ObservableObject
{
+ ///
+ /// Label for packages sitting directly in a category that also has sub categories.
+ ///
+ private const string OwnGroupHeader = "Other";
+
+ private const string OwnGroupKey = "own";
+
+ private sealed record PackageGroup(string? Key, string? Header, IReadOnlyList Packages);
+
+ private readonly List _packages = [];
+
+ ///
+ /// Ids that were visible at the last user triggered relayout. They stay visible across data
+ /// changes so that installing a package does not make its row disappear or move.
+ ///
+ private readonly HashSet _pinnedIds = [];
+
+ private readonly Dictionary _separatorCache = new();
+
+ private int _displayCount;
private bool _isExpanded = true;
+ private bool _isVisible = true;
+ private PackageListQuery _query = PackageListQuery.Default;
private PackageViewModel? _selectedPackage;
+ ///
+ /// The expansion state the user chose, restored once a search is cleared again.
+ ///
+ private bool _userExpanded = true;
+
+ private bool _suppressExpansionTracking;
+
+ public PackageCategoryViewModel(string header, PackageCategoryKind kind = PackageCategoryKind.Normal)
+ {
+ Header = header;
+ Kind = kind;
+ }
+
+ public string Header { get; }
+
+ public PackageCategoryKind Kind { get; }
+
public bool IsExpanded
{
get => _isExpanded;
- set => SetProperty(ref _isExpanded, value);
+ set
+ {
+ if (!SetProperty(ref _isExpanded, value)) return;
+ if (!_suppressExpansionTracking) _userExpanded = value;
+ }
+ }
+
+ ///
+ /// Whether the category is shown in the category tree.
+ ///
+ public bool IsVisible
+ {
+ get => _isVisible;
+ private set => SetProperty(ref _isVisible, value);
+ }
+
+ ///
+ /// Count badge shown in the tree. For the updates node this is a live count, so it can change
+ /// without relayouting anything.
+ ///
+ public int DisplayCount
+ {
+ get => _displayCount;
+ private set => SetProperty(ref _displayCount, value);
}
public PackageViewModel? SelectedPackage
@@ -23,76 +83,156 @@ public PackageViewModel? SelectedPackage
set => SetProperty(ref _selectedPackage, value);
}
- public List Packages { get; } = [];
+ public IReadOnlyList Packages => _packages;
public ObservableCollection VisiblePackages { get; } = [];
public ObservableCollection VisibleEntries { get; } = [];
- public ObservableCollection VisibleSeparators { get; } = [];
-
public ObservableCollection SubCategories { get; } = [];
- public IconModel? IconModel { get; } = iconModel;
+ public PackageCategoryViewModel GetOrCreateSubCategory(string header,
+ PackageCategoryKind kind = PackageCategoryKind.Normal)
+ {
+ var existing = SubCategories.FirstOrDefault(x =>
+ x.Kind == kind && x.Header.Equals(header, StringComparison.OrdinalIgnoreCase));
+ if (existing != null) return existing;
- public string Header { get; } = header;
+ var category = new PackageCategoryViewModel(header, kind);
+ SubCategories.Add(category);
+ return category;
+ }
public void Add(PackageViewModel model)
{
- Packages.Add(model);
+ _packages.Add(model);
}
- public void Remove(PackageViewModel model)
+ public void ClearPackages()
{
- Packages.Remove(model);
- VisiblePackages.Remove(model);
- VisibleEntries.Remove(model);
+ _packages.Clear();
}
- public void Filter(string filter, bool showInstalled, bool showAvailable)
+ public void SetPackages(IEnumerable packages)
{
- var filtered =
- Packages.Where(x =>
- x.PackageState.Package.Name?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
+ // Materialise first, the caller may pass a lazy sequence reading from this very list.
+ var materialized = packages.ToList();
- if (!showInstalled)
- filtered = filtered.Where(x => !IsInstalledPackage(x.PackageState.Status));
+ _packages.Clear();
+ _packages.AddRange(materialized);
+ }
+
+ ///
+ /// Recomputes membership and order for a new query. This is the only place where rows are
+ /// allowed to move, and it only runs in response to a user action.
+ ///
+ public void Relayout(PackageListQuery query)
+ {
+ Apply(query, honorPins: false);
+ }
- if (!showAvailable)
- filtered = filtered.Where(x => IsInstalledPackage(x.PackageState.Status));
+ ///
+ /// Reapplies the current query after a data change. Rows that were visible before stay visible
+ /// in place, so installing or updating a package never disturbs the list.
+ ///
+ public void Resync()
+ {
+ Apply(_query, honorPins: true);
+ }
+
+ private void Apply(PackageListQuery query, bool honorPins)
+ {
+ // A category whose header matches the search shows all of its packages, so searching works for
+ // categories and packages alike.
+ var headerMatches = Kind == PackageCategoryKind.Normal && MatchesFilter(Header, query.Filter);
+ var effective = headerMatches ? query with { Filter = string.Empty } : query;
+
+ _query = effective;
foreach (var subCategory in SubCategories)
+ subCategory.Apply(effective, honorPins);
+
+ Rebuild(headerMatches, honorPins);
+ }
+
+ private void Rebuild(bool headerMatches, bool honorPins)
+ {
+ var own = _packages.Where(x => IsEligible(x, honorPins)).Distinct().ToList();
+ own.Sort(new PackageListComparer(_query.Filter));
+
+ // The updates node is a view onto packages that already live in a real category, it must not
+ // contribute to the aggregate of its parent.
+ var subGroups = SubCategories
+ .Where(x => x.Kind != PackageCategoryKind.Updates && x.VisiblePackages.Count > 0)
+ .Select(x => (Header: x.Header, Packages: (IReadOnlyList)x.VisiblePackages.ToList()))
+ .ToList();
+
+ var groups = new List();
+
+ if (subGroups.Count == 0)
{
- subCategory.Filter(filter, showInstalled, showAvailable);
- filtered = filtered.Concat(subCategory.VisiblePackages);
+ if (own.Count > 0) groups.Add(new PackageGroup(null, null, own));
}
+ else
+ {
+ // The own group and a sub category could carry the same label, so separators are cached by a
+ // key that keeps their instances distinct.
+ if (own.Count > 0) groups.Add(new PackageGroup(OwnGroupKey, OwnGroupHeader, own));
- var orderedPackages = filtered
- .OrderBy(GetPackageGroupPriority)
- .ThenBy(x => x.PackageState.Package.Name, StringComparer.OrdinalIgnoreCase)
- .ToList();
+ foreach (var subGroup in subGroups)
+ groups.Add(new PackageGroup("sub:" + subGroup.Header, subGroup.Header, subGroup.Packages));
+ }
- VisiblePackages.Clear();
- VisiblePackages.AddRange(orderedPackages);
+ ObservableCollectionReconciler.Reconcile(VisiblePackages, groups.SelectMany(x => x.Packages).ToList());
+ ObservableCollectionReconciler.Reconcile(VisibleEntries, BuildEntries(groups));
- VisibleEntries.Clear();
- VisibleEntries.AddRange(CreateVisibleEntries(orderedPackages));
+ if (!honorPins)
+ {
+ _pinnedIds.Clear();
+ foreach (var package in VisiblePackages)
+ _pinnedIds.Add(package.PackageState.Package.Id ?? string.Empty);
+ }
- VisibleSeparators.Clear();
- VisibleSeparators.AddRange(VisibleEntries.OfType());
+ if (Kind != PackageCategoryKind.Updates)
+ {
+ DisplayCount = VisiblePackages.Count;
+ IsVisible = Kind == PackageCategoryKind.Root || headerMatches || VisiblePackages.Count > 0;
+ }
+
+ _suppressExpansionTracking = true;
+ // While searching everything is expanded so matches inside collapsed branches stay reachable.
+ IsExpanded = _query.HasSearch || _userExpanded;
+ _suppressExpansionTracking = false;
}
- private static int GetPackageGroupPriority(PackageViewModel package)
+ ///
+ /// Sets the live update count for the updates node, which changes as packages are installed
+ /// without triggering any relayout.
+ ///
+ public void SetLiveCount(int count)
{
- return package.PackageState.Status switch
- {
- PackageStatus.UpdateAvailable => 0,
- PackageStatus.UpdateAvailablePrerelease => 0,
- PackageStatus.Installed => 1,
- PackageStatus.NeedRestart => 1,
- PackageStatus.Installing => 1,
- _ => 2
- };
+ DisplayCount = count;
+ IsVisible = count > 0 || VisiblePackages.Count > 0;
+ }
+
+ private bool IsEligible(PackageViewModel package, bool honorPins)
+ {
+ if (honorPins && _pinnedIds.Contains(package.PackageState.Package.Id ?? string.Empty))
+ return true;
+
+ if (!MatchesFilter(package.PackageState.Package.Name, _query.Filter)) return false;
+
+ var installed = IsInstalledPackage(package.PackageState.Status);
+ if (!_query.ShowInstalled && installed) return false;
+ if (!_query.ShowAvailable && !installed) return false;
+
+ return true;
+ }
+
+ private static bool MatchesFilter(string? value, string filter)
+ {
+ if (string.IsNullOrEmpty(filter)) return true;
+ return value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false;
}
private static bool IsInstalledPackage(PackageStatus status)
@@ -104,37 +244,32 @@ or PackageStatus.NeedRestart
or PackageStatus.Installing;
}
- private static IReadOnlyList CreateVisibleEntries(IReadOnlyList packages)
+ private IReadOnlyList BuildEntries(IReadOnlyList groups)
{
- var groups = packages
- .GroupBy(GetPackageGroupPriority)
- .OrderBy(x => x.Key)
- .ToList();
+ if (groups.Count == 0) return [];
- if (groups.Count <= 1)
- return packages.Cast().ToList();
+ if (groups.Count == 1 && groups[0].Key == null)
+ return groups[0].Packages.Cast().ToList();
- var entries = new List(packages.Count + groups.Count);
+ var entries = new List();
- for (var groupIndex = 0; groupIndex < groups.Count; groupIndex++)
+ foreach (var group in groups)
{
- var group = groups[groupIndex];
- entries.Add(new PackageSeparatorViewModel(GetGroupLabel(group.Key), groupIndex > 0));
-
- foreach (var package in group)
- entries.Add(package);
+ entries.Add(GetSeparator(group.Key!, group.Header!));
+ entries.AddRange(group.Packages);
}
return entries;
}
- private static string GetGroupLabel(int groupPriority)
+ private PackageSeparatorViewModel GetSeparator(string key, string label)
{
- return groupPriority switch
+ if (!_separatorCache.TryGetValue(key, out var separator))
{
- 0 => "Update Available",
- 1 => "Installed",
- _ => "Available"
- };
+ separator = new PackageSeparatorViewModel(label);
+ _separatorCache[key] = separator;
+ }
+
+ return separator;
}
}
diff --git a/src/OneWare.PackageManager/ViewModels/PackageListComparer.cs b/src/OneWare.PackageManager/ViewModels/PackageListComparer.cs
new file mode 100644
index 00000000..a3da6b40
--- /dev/null
+++ b/src/OneWare.PackageManager/ViewModels/PackageListComparer.cs
@@ -0,0 +1,41 @@
+namespace OneWare.PackageManager.ViewModels;
+
+///
+/// Orders packages by a key that never depends on package status, so installing or updating a
+/// package can never move its row. While searching, a relevance tier is applied first, which also
+/// only depends on the query.
+///
+public sealed class PackageListComparer(string filter) : IComparer
+{
+ public int Compare(PackageViewModel? x, PackageViewModel? y)
+ {
+ if (ReferenceEquals(x, y)) return 0;
+ if (x == null) return -1;
+ if (y == null) return 1;
+
+ var relevance = GetRelevance(x.PackageState.Package.Name, filter)
+ .CompareTo(GetRelevance(y.PackageState.Package.Name, filter));
+ if (relevance != 0) return relevance;
+
+ var name = string.Compare(x.PackageState.Package.Name, y.PackageState.Package.Name,
+ StringComparison.OrdinalIgnoreCase);
+ if (name != 0) return name;
+
+ // Only the id is guaranteed unique, it keeps the order deterministic for duplicate names.
+ return string.Compare(x.PackageState.Package.Id, y.PackageState.Package.Id,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Lower is more relevant: exact match, then prefix match, then substring match.
+ ///
+ public static int GetRelevance(string? name, string filter)
+ {
+ if (string.IsNullOrEmpty(filter)) return 0;
+ if (string.IsNullOrEmpty(name)) return 3;
+
+ if (name.Equals(filter, StringComparison.OrdinalIgnoreCase)) return 0;
+ if (name.StartsWith(filter, StringComparison.OrdinalIgnoreCase)) return 1;
+ return name.Contains(filter, StringComparison.OrdinalIgnoreCase) ? 2 : 3;
+ }
+}
diff --git a/src/OneWare.PackageManager/ViewModels/PackageListQuery.cs b/src/OneWare.PackageManager/ViewModels/PackageListQuery.cs
new file mode 100644
index 00000000..449cfb5a
--- /dev/null
+++ b/src/OneWare.PackageManager/ViewModels/PackageListQuery.cs
@@ -0,0 +1,12 @@
+namespace OneWare.PackageManager.ViewModels;
+
+///
+/// Everything the package list layout depends on. Membership and order are a pure function of this
+/// query, so they only change when the user changes it, never when package data changes.
+///
+public sealed record PackageListQuery(string Filter, bool ShowInstalled, bool ShowAvailable)
+{
+ public static readonly PackageListQuery Default = new(string.Empty, true, true);
+
+ public bool HasSearch => !string.IsNullOrEmpty(Filter);
+}
diff --git a/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs
index dc26f1cd..f8736e52 100644
--- a/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs
+++ b/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs
@@ -20,6 +20,13 @@ namespace OneWare.PackageManager.ViewModels;
public class PackageManagerViewModel : FlexibleWindowViewModelBase, IPackageWindowService
{
private const string AllCategoryHeader = "All";
+ private const string UpdatesCategoryHeader = "Updates";
+
+ ///
+ /// The package promoted by the hero banner above the list.
+ ///
+ private const string FeaturedPackageId = "OneWare.AI";
+
private static readonly char[] CategorySeparators = ['/', '\\'];
private readonly IApplicationStateService _applicationStateService;
@@ -28,6 +35,11 @@ public class PackageManagerViewModel : FlexibleWindowViewModelBase, IPackageWind
private readonly IPackageService _packageService;
private readonly IWindowService _windowService;
+ private readonly PackageCategoryViewModel _allCategory;
+ private readonly PackageCategoryViewModel _updatesCategory;
+
+ private readonly Dictionary _packageViewModels = new();
+
private bool _showAvailable = true;
private bool _showInstalled = true;
private int _selectedFilterIndex;
@@ -42,21 +54,32 @@ public PackageManagerViewModel(IPackageService packageService, IHttpService http
_logger = logger;
_applicationStateService = applicationStateService;
- RegisterCategory(AllCategoryHeader);
- RegisterCategory("Plugins", new IconModel("BoxIcons.RegularExtension"));
- RegisterCategory("Plugins/Languages", new IconModel("FluentIcons.ProofreadLanguageRegular"));
- RegisterCategory("Plugins/Toolchains", new IconModel("FeatherIcons.Tool"));
- RegisterCategory("Plugins/Simulators", new IconModel("Material.Pulse"));
- RegisterCategory("Plugins/Tools", new IconModel("Module"));
- RegisterCategory("Hardware", new IconModel("NiosIcon"));
+ _allCategory = new PackageCategoryViewModel(AllCategoryHeader, PackageCategoryKind.Root);
+ PackageCategories.Add(_allCategory);
+
+ _updatesCategory = _allCategory.GetOrCreateSubCategory(UpdatesCategoryHeader, PackageCategoryKind.Updates);
+
+ FeaturedPackage = new FeaturedPackageViewModel(
+ "ONE AI is available",
+ "Manage your ONE AI projects directly in the IDE and get annotation, model training and real-time camera checking.",
+ "AI_Img",
+ "https://one-ware.com/one-ai",
+ new AsyncRelayCommand(ShowFeaturedPackageDetailsAsync));
+
+ RegisterCategory("Plugins");
+ RegisterCategory("Plugins/Languages");
+ RegisterCategory("Plugins/Toolchains");
+ RegisterCategory("Plugins/Simulators");
+ RegisterCategory("Plugins/Tools");
+ RegisterCategory("Hardware");
RegisterCategory("Hardware/FPGA Boards");
RegisterCategory("Hardware/Extensions");
- RegisterCategory("Libraries", new IconModel("BoxIcons.RegularLibrary"));
- RegisterCategory("Binaries", new IconModel("BoxIcons.RegularCode"));
+ RegisterCategory("Libraries");
+ RegisterCategory("Binaries");
RegisterCategory("Binaries/ONNX Runtimes");
- RegisterCategory("Drivers", new IconModel("BoxIcons.RegularUsb"));
+ RegisterCategory("Drivers");
- SelectedCategory = GetAllCategory() ?? PackageCategories.FirstOrDefault();
+ SelectedCategory = _allCategory;
_packageService.WhenValueChanged(x => x.IsUpdating)
.Subscribe(x => Dispatcher.UIThread.Post(() => IsLoading = x));
@@ -75,6 +98,7 @@ public PackageManagerViewModel(IPackageService packageService, IHttpService http
});
ConstructPackageViewModels();
+ Relayout();
}
public bool ShowInstalled
@@ -83,7 +107,7 @@ public bool ShowInstalled
set
{
SetProperty(ref _showInstalled, value);
- FilterPackages();
+ Relayout();
}
}
@@ -93,7 +117,7 @@ public bool ShowAvailable
set
{
SetProperty(ref _showAvailable, value);
- FilterPackages();
+ Relayout();
}
}
@@ -108,7 +132,7 @@ public int SelectedFilterIndex
SetProperty(ref _selectedFilterIndex, value);
_showInstalled = value is 0 or 1;
_showAvailable = value is 0 or 2;
- FilterPackages();
+ Relayout();
}
}
@@ -118,7 +142,7 @@ public string Filter
set
{
SetProperty(ref field, value);
- FilterPackages();
+ Relayout();
}
} = string.Empty;
@@ -136,32 +160,29 @@ public PackageCategoryViewModel? SelectedCategory
public ObservableCollection PackageCategories { get; } = [];
+ ///
+ /// Pinned above the list, never part of it, so promoting a package cannot shift any row.
+ ///
+ public FeaturedPackageViewModel FeaturedPackage { get; }
+
public bool AskForRestart { get; set; } = true;
public AsyncRelayCommand UpdateAllCommand { get; }
+ ///
+ /// Registers a category. is accepted for API compatibility but no
+ /// longer used, categories are listed without icons.
+ ///
public void RegisterCategory(string categoryPath, IconModel? iconModel = null)
{
var segments = SplitCategoryPath(categoryPath);
if (segments.Length == 0) return;
- PackageCategoryViewModel? current = null;
-
- for (var i = 0; i < segments.Length; i++)
- {
- var header = NormalizeCategorySegment(segments[i], i == segments.Length - 1);
- var categories = current == null ? PackageCategories : current.SubCategories;
+ var current = _allCategory;
- var existing = FindCategory(categories, header);
- if (existing == null)
- {
- var category = new PackageCategoryViewModel(header, i == segments.Length - 1 ? iconModel : null);
- categories.Add(category);
- existing = category;
- }
-
- current = existing;
- }
+ foreach (var (segment, index) in segments.Select((x, i) => (x, i)))
+ current = current.GetOrCreateSubCategory(
+ NormalizeCategorySegment(segment, index == segments.Length - 1));
}
public async Task RefreshPackagesAsync()
@@ -249,8 +270,8 @@ public async Task ResolveSelectedPackageTabsAsync()
private bool FocusCategory(string category, string? subcategory)
{
- var categoryVm = PackageCategories
- .FirstOrDefault(x => x.Header == category);
+ var categoryVm = _allCategory.SubCategories
+ .FirstOrDefault(x => x.Kind == PackageCategoryKind.Normal && x.Header == category);
if (categoryVm == null)
return false;
@@ -269,10 +290,10 @@ private bool FocusCategory(string category, string? subcategory)
private async Task FocusPluginAsync(string packageId)
{
- var categoryVm = PackageCategories
- .Where(x => !x.Header.Equals(AllCategoryHeader, StringComparison.OrdinalIgnoreCase))
- .FirstOrDefault(x => x.VisiblePackages.Any(y => y.PackageState.Package.Id == packageId))
- ?? GetAllCategory();
+ var categoryVm = _allCategory.SubCategories
+ .FirstOrDefault(x => x.Kind == PackageCategoryKind.Normal &&
+ x.VisiblePackages.Any(y => y.PackageState.Package.Id == packageId))
+ ?? _allCategory;
if (categoryVm != null && _packageService.Packages.TryGetValue(packageId, out var packageModel))
{
@@ -293,24 +314,42 @@ private bool FocusCategory(string category, string? subcategory)
return null;
}
- private void ConstructPackageViewModels()
+ ///
+ /// The banner ignores the search text and the segment filter, so the promoted package is not
+ /// necessarily laid out. The query is reset first, otherwise focusing it would silently do nothing.
+ ///
+ private async Task ShowFeaturedPackageDetailsAsync()
{
- var allCategory = GetAllCategory();
+ if (SelectedFilterIndex != 0) SelectedFilterIndex = 0;
+ if (!string.IsNullOrEmpty(Filter)) Filter = string.Empty;
+ await FocusPluginAsync(FeaturedPackageId);
+ }
+
+ private void ConstructPackageViewModels() {
foreach (var category in PackageCategories)
ClearCategoryPackages(category);
- foreach (var (_, packageModel) in _packageService.Packages)
+ var staleIds = _packageViewModels.Keys
+ .Where(x => !_packageService.Packages.ContainsKey(x))
+ .ToList();
+
+ foreach (var staleId in staleIds)
+ {
+ if (!_packageViewModels.Remove(staleId, out var staleViewModel)) continue;
+
+ staleViewModel.StatusChanged -= OnPackageStatusChanged;
+ staleViewModel.Dispose();
+ }
+
+ foreach (var (packageId, packageModel) in _packageService.Packages)
try
{
- var viewModel =
- new PackageViewModel(packageModel, _packageService, _httpService, _windowService, _applicationStateService, _logger);
+ var viewModel = GetOrCreatePackageViewModel(packageId, packageModel);
- var targetCategory = ResolveCategoryForPackage(packageModel.Package);
- if (targetCategory == null) continue;
-
- if (allCategory != null && !ReferenceEquals(allCategory, targetCategory))
- allCategory.Add(viewModel);
+ // The root category aggregates all sub category packages, so only the concrete
+ // category has to be filled here.
+ var targetCategory = ResolveCategoryForPackage(packageModel.Package) ?? _allCategory;
targetCategory.Add(viewModel);
}
@@ -319,13 +358,98 @@ private void ConstructPackageViewModels()
_logger.Error(e.Message, e);
}
- FilterPackages();
+ RefreshUpdatesCategory(false);
+
+ // Runs after the stale view models were evicted and disposed, so the banner can never hold a
+ // disposed view model.
+ FeaturedPackage.Target = _packageViewModels.GetValueOrDefault(FeaturedPackageId);
+
+ // Package data changed. Rows are updated in place, membership and order stay untouched.
+ SyncSelection(() => _allCategory.Resync());
+ }
+
+ ///
+ /// Reuses the view model of a package across reconstructions. Recreating it would drop the list
+ /// selection, the resolved icon and the resolved tabs of the package the user is looking at.
+ ///
+ private PackageViewModel GetOrCreatePackageViewModel(string packageId, IPackageState packageModel)
+ {
+ if (_packageViewModels.TryGetValue(packageId, out var existing))
+ {
+ // RefreshAsync replaces the package states, the view model has to follow.
+ if (!ReferenceEquals(existing.PackageState, packageModel))
+ existing.PackageState = packageModel;
+
+ return existing;
+ }
+
+ var viewModel = new PackageViewModel(packageModel, _packageService, _httpService, _windowService,
+ _applicationStateService, _logger);
+
+ viewModel.StatusChanged += OnPackageStatusChanged;
+ _packageViewModels[packageId] = viewModel;
+ return viewModel;
+ }
+
+ private void OnPackageStatusChanged(object? sender, EventArgs e)
+ {
+ RefreshUpdateCount();
+ }
+
+ ///
+ /// Recomputes membership and order of the whole tree. Only ever called in response to a user
+ /// action, never because package data changed.
+ ///
+ private void Relayout()
+ {
+ RefreshUpdatesCategory(true);
+
+ var query = new PackageListQuery(Filter, _showInstalled, _showAvailable);
+ SyncSelection(() => _allCategory.Relayout(query));
+ }
+
+ ///
+ /// The list box drops its selection while its items are reconciled, which would blank the detail
+ /// pane. The selected package is therefore restored by id afterwards.
+ ///
+ private void SyncSelection(Action layout)
+ {
+ var selectedPackageId = SelectedCategory?.SelectedPackage?.PackageState.Package.Id;
+
+ layout();
+
+ // Depends on the freshly laid out content, so it has to run after the layout.
+ RefreshUpdateCount();
+
+ if (SelectedCategory is { IsVisible: false })
+ SelectedCategory = _allCategory;
+
+ if (SelectedCategory == null) return;
+
+ SelectedCategory.SelectedPackage = selectedPackageId == null
+ ? null
+ : SelectedCategory.VisiblePackages.FirstOrDefault(x => x.PackageState.Package.Id == selectedPackageId);
+ }
+
+ ///
+ /// Refreshes the smart updates category. On a data change the previous content is kept, so a
+ /// package updated from within the category stays in place instead of being pulled away.
+ ///
+ private void RefreshUpdatesCategory(bool relayout)
+ {
+ var live = _packageViewModels.Values.ToHashSet();
+ var updatable = live.Where(x => x.HasUpdate);
+
+ // Carried over packages must still exist, otherwise an evicted and disposed view model would be
+ // resurrected and keep rendering a row for a package the service no longer knows.
+ _updatesCategory.SetPackages(relayout
+ ? updatable
+ : updatable.Concat(_updatesCategory.Packages.Where(live.Contains)).Distinct());
}
- private void FilterPackages()
+ private void RefreshUpdateCount()
{
- foreach (var categoryModel in PackageCategories)
- categoryModel.Filter(Filter, _showInstalled, _showAvailable);
+ _updatesCategory.SetLiveCount(_packageViewModels.Values.Count(x => x.HasUpdate));
}
public override bool OnWindowClosing(FlexibleWindow window)
@@ -455,19 +579,6 @@ private static string NormalizeCategorySegment(string segment, bool isLeaf)
x.Header.Equals(header, StringComparison.OrdinalIgnoreCase));
}
- private static PackageCategoryViewModel GetOrCreateCategory(
- IList categories,
- string header,
- IconModel? iconModel = null)
- {
- var existing = FindCategory(categories, header);
- if (existing != null) return existing;
-
- var category = new PackageCategoryViewModel(header, iconModel);
- categories.Add(category);
- return category;
- }
-
private PackageCategoryViewModel? ResolveCategoryForPackage(Essentials.PackageManager.Package package)
{
var rawCategory = package.Category;
@@ -479,14 +590,11 @@ private static PackageCategoryViewModel GetOrCreateCategory(
var segments = SplitCategoryPath(rawCategory);
if (segments.Length == 0) return ResolveRootCategoryForType(package.Type);
- var root = GetOrCreateCategory(PackageCategories, segments[0]);
- var current = root;
+ var current = _allCategory.GetOrCreateSubCategory(segments[0]);
for (var i = 1; i < segments.Length; i++)
- {
- var header = NormalizeCategorySegment(segments[i], i == segments.Length - 1);
- current = GetOrCreateCategory(current.SubCategories, header);
- }
+ current = current.GetOrCreateSubCategory(
+ NormalizeCategorySegment(segments[i], i == segments.Length - 1));
return current;
}
@@ -500,14 +608,7 @@ private static PackageCategoryViewModel GetOrCreateCategory(
wantedCategory = NormalizeCategorySegment(wantedCategory, true);
- var subCategory = FindCategory(category.SubCategories, wantedCategory);
- if (subCategory == null)
- {
- subCategory = new PackageCategoryViewModel(wantedCategory);
- category.SubCategories.Add(subCategory);
- }
-
- return subCategory;
+ return category.GetOrCreateSubCategory(wantedCategory);
}
private PackageCategoryViewModel? ResolveRootCategoryForType(string? packageType)
@@ -525,18 +626,13 @@ private static PackageCategoryViewModel GetOrCreateCategory(
};
if (rootCategoryName == null) return null;
- return FindCategory(PackageCategories, rootCategoryName);
- }
-
- private PackageCategoryViewModel? GetAllCategory()
- {
- return FindCategory(PackageCategories, AllCategoryHeader);
+ return FindCategory(_allCategory.SubCategories, rootCategoryName);
}
private static void ClearCategoryPackages(PackageCategoryViewModel category)
{
- foreach (var pkg in category.Packages.ToArray())
- category.Remove(pkg);
+ // Only the backing list is cleared, the visible collections are reconciled afterwards.
+ if (category.Kind != PackageCategoryKind.Updates) category.ClearPackages();
foreach (var subCategory in category.SubCategories)
ClearCategoryPackages(subCategory);
diff --git a/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs
index c4386079..d439132a 100644
--- a/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs
+++ b/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs
@@ -1,10 +1,8 @@
namespace OneWare.PackageManager.ViewModels;
-public sealed class PackageSeparatorViewModel(string text, bool showLine) : PackageListEntryViewModel
+public sealed class PackageSeparatorViewModel(string text) : PackageListEntryViewModel
{
public string Text { get; } = text;
- public bool ShowLine { get; } = showLine;
-
public override bool IsSelectable => false;
}
diff --git a/src/OneWare.PackageManager/ViewModels/PackageViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageViewModel.cs
index 1e8c12d6..d813e472 100644
--- a/src/OneWare.PackageManager/ViewModels/PackageViewModel.cs
+++ b/src/OneWare.PackageManager/ViewModels/PackageViewModel.cs
@@ -18,7 +18,7 @@
namespace OneWare.PackageManager.ViewModels;
-public class PackageViewModel : PackageListEntryViewModel
+public class PackageViewModel : PackageListEntryViewModel, IDisposable
{
private readonly IHttpService _httpService;
private readonly IPackageService _packageService;
@@ -30,10 +30,18 @@ public class PackageViewModel : PackageListEntryViewModel
private IDisposable? _primaryButtonBrushSubscription;
+ private IDisposable? _statusSubscription;
+
private bool _resolveImageStarted;
private bool _resolveTabsStarted;
+ ///
+ /// Identifies the current tab resolve run. A package state swap invalidates an in flight run so a
+ /// stale one cannot append its results to the tabs of the new package.
+ ///
+ private int _resolveTabsGeneration;
+
public PackageViewModel(IPackageState packageState, IPackageService packageService, IHttpService httpService,
IWindowService windowService, IApplicationStateService applicationStateService, ILogger logger)
{
@@ -61,7 +69,7 @@ public PackageViewModel(IPackageState packageState, IPackageService packageServi
CancelCommand = new RelayCommand(() => _packageService.CancelInstall(PackageState.Package.Id!),
() => PackageState.Status is PackageStatus.Installing);
- PackageState.WhenValueChanged(x => x.Status).Subscribe(_ => UpdateStatus());
+ SubscribeToStatus();
InitPackage();
}
@@ -71,12 +79,28 @@ public bool IsTabsResolved
set => SetProperty(ref field, value);
}
+ ///
+ /// Whether an update is available. Shown as a badge on the row, it never affects the position of
+ /// the package in the list.
+ ///
+ public bool HasUpdate => PackageState.Status is PackageStatus.UpdateAvailable
+ or PackageStatus.UpdateAvailablePrerelease;
+
+ ///
+ /// Raised whenever the package status changed, used to keep the live update count in sync.
+ ///
+ public event EventHandler? StatusChanged;
+
public IPackageState PackageState
{
get => _packageState;
set
{
+ if (ReferenceEquals(_packageState, value)) return;
+
SetProperty(ref _packageState, value);
+ // The status observable is bound to a single state instance, it has to follow the swap.
+ SubscribeToStatus();
InitPackage();
}
}
@@ -133,6 +157,20 @@ public ICommand? MainButtonCommand
///
public ICommand ResolveIconCommand { get; }
+ public void Dispose()
+ {
+ _statusSubscription?.Dispose();
+ _statusSubscription = null;
+ _primaryButtonBrushSubscription?.Dispose();
+ _primaryButtonBrushSubscription = null;
+ }
+
+ private void SubscribeToStatus()
+ {
+ _statusSubscription?.Dispose();
+ _statusSubscription = PackageState.WhenValueChanged(x => x.Status).Subscribe(_ => UpdateStatus());
+ }
+
private void InitPackage()
{
Links.Clear();
@@ -153,15 +191,20 @@ private void InitPackage()
SelectedVersionModel = PackageVersionModels.FirstOrDefault(x => x.Version == target);
+ var tabsWereResolved = _resolveTabsStarted;
_resolveTabsStarted = false;
+ _resolveTabsGeneration++;
+ Tabs.Clear();
+ IsTabsResolved = false;
var iconWasRequested = _resolveImageStarted;
_resolveImageStarted = false;
UpdateStatus();
- // Only reload the icon if it was requested before, icons are resolved lazily when the package becomes visible.
+ // Only reload icon and tabs if they were requested before, both are resolved lazily.
if (iconWasRequested) _ = ResolveIconAsync();
+ if (tabsWereResolved) _ = ResolveTabsAsync();
}
private void UpdateStatus()
@@ -215,6 +258,9 @@ private void UpdateStatus()
PrimaryButtonBrush = x as IBrush;
});
+ OnPropertyChanged(nameof(HasUpdate));
+ StatusChanged?.Invoke(this, EventArgs.Empty);
+
RemoveCommand.NotifyCanExecuteChanged();
InstallCommand.NotifyCanExecuteChanged();
UpdateCommand.NotifyCanExecuteChanged();
@@ -313,18 +359,30 @@ public async Task ResolveTabsAsync()
{
if (_resolveTabsStarted) return;
_resolveTabsStarted = true;
+
+ var generation = _resolveTabsGeneration;
+
IsTabsResolved = false;
Tabs.Clear();
+ // Collected separately, the awaits below give a newer run the chance to take over.
+ var resolved = new List();
+
if (PackageState.Package.Tabs != null)
foreach (var tab in PackageState.Package.Tabs)
{
if (tab.ContentUrl == null) continue;
var content = await _httpService.DownloadTextAsync(tab.ContentUrl);
- Tabs.Add(new TabModel(tab.Title ?? "Title", content ?? "Failed Loading Content"));
+ if (generation != _resolveTabsGeneration) return;
+
+ resolved.Add(new TabModel(tab.Title ?? "Title", content ?? "Failed Loading Content"));
}
+ if (generation != _resolveTabsGeneration) return;
+
+ Tabs.Clear();
+ Tabs.AddRange(resolved);
IsTabsResolved = true;
}
diff --git a/src/OneWare.PackageManager/Views/PackageManagerView.axaml b/src/OneWare.PackageManager/Views/PackageManagerView.axaml
index b9936066..065897b2 100644
--- a/src/OneWare.PackageManager/Views/PackageManagerView.axaml
+++ b/src/OneWare.PackageManager/Views/PackageManagerView.axaml
@@ -31,7 +31,7 @@
-
+
@@ -65,31 +65,36 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -138,7 +167,6 @@
+
+
+
@@ -199,7 +234,7 @@
-
diff --git a/src/OneWare.PackageManager/Views/PackageManagerView.axaml.cs b/src/OneWare.PackageManager/Views/PackageManagerView.axaml.cs
index 9866dc35..12d67c75 100644
--- a/src/OneWare.PackageManager/Views/PackageManagerView.axaml.cs
+++ b/src/OneWare.PackageManager/Views/PackageManagerView.axaml.cs
@@ -27,7 +27,10 @@ private void PackageSeparatorButton_OnClick(object? sender, RoutedEventArgs e)
if (DataContext is not PackageManagerViewModel viewModel)
return;
- var separators = viewModel.SelectedCategory?.VisibleSeparators;
+ var separators = viewModel.SelectedCategory?.VisibleEntries
+ .OfType()
+ .ToList();
+
if (separators == null || separators.Count == 0)
return;
diff --git a/src/OneWare.PackageManager/Views/PackageView.axaml b/src/OneWare.PackageManager/Views/PackageView.axaml
index 131e9aeb..268b68ba 100644
--- a/src/OneWare.PackageManager/Views/PackageView.axaml
+++ b/src/OneWare.PackageManager/Views/PackageView.axaml
@@ -8,6 +8,7 @@
xmlns:converters="clr-namespace:OneWare.Essentials.Converters;assembly=OneWare.Essentials"
xmlns:enums="clr-namespace:OneWare.Essentials.Enums;assembly=OneWare.Essentials"
xmlns:packageManager="clr-namespace:OneWare.Essentials.PackageManager;assembly=OneWare.Essentials"
+ xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" Padding="5"
x:Class="OneWare.PackageManager.Views.PackageView" x:DataType="viewModels:PackageViewModel">
@@ -89,7 +90,7 @@
-
+
@@ -100,7 +101,7 @@
@@ -117,7 +118,13 @@
IsVisible="{Binding SelectedVersionModel.CompatibilityReport, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue={x:False}}">
+ Markdown="{Binding SelectedVersionModel.CompatibilityReport.Report, FallbackValue={x:Null}}">
+
+
+
+
@@ -143,7 +150,8 @@
-
+
@@ -155,4 +163,4 @@
-
+
\ No newline at end of file
diff --git a/src/OneWare.TypeScript/TypeScriptModule.cs b/src/OneWare.TypeScript/TypeScriptModule.cs
index 7f7b9ce1..bc90c43f 100644
--- a/src/OneWare.TypeScript/TypeScriptModule.cs
+++ b/src/OneWare.TypeScript/TypeScriptModule.cs
@@ -27,7 +27,8 @@ public class TypeScriptModule : OneWareModuleBase
Name = "TypeScript (native tsc)",
Description = "Used for JavaScript and TypeScript Support",
License = "Apache 2.0",
- IconUrl = "https://raw.githubusercontent.com/lobehub/lobe-icons/refs/heads/master/packages/static-png/dark/typescript.png",
+ IconUrl =
+ "https://raw.githubusercontent.com/microsoft/TypeScript-Website/v2/packages/typescriptlang-org/static/branding/ts-logo-512.png",
Links =
[
new PackageLink
diff --git a/tests/OneWare.PackageManager.UnitTests/FeaturedPackageViewModelTests.cs b/tests/OneWare.PackageManager.UnitTests/FeaturedPackageViewModelTests.cs
new file mode 100644
index 00000000..9574840d
--- /dev/null
+++ b/tests/OneWare.PackageManager.UnitTests/FeaturedPackageViewModelTests.cs
@@ -0,0 +1,127 @@
+using Avalonia.Headless.XUnit;
+using CommunityToolkit.Mvvm.Input;
+using OneWare.Essentials.Enums;
+using OneWare.PackageManager.ViewModels;
+using Xunit;
+
+namespace OneWare.PackageManager.UnitTests;
+
+public class FeaturedPackageViewModelTests
+{
+ private static FeaturedPackageViewModel Create()
+ {
+ return new FeaturedPackageViewModel("Title", "Description", "AI_Img", "https://one-ware.com/one-ai",
+ new RelayCommand(() => { }));
+ }
+
+ [AvaloniaFact]
+ public void HiddenWithoutTarget()
+ {
+ Assert.False(Create().IsVisible);
+ }
+
+ [AvaloniaFact]
+ public void VisibleWhileAvailable()
+ {
+ var featured = Create();
+ featured.Target = PackageTestFactory.CreateViewModel("OneWare.AI", "ONE AI", PackageStatus.Available);
+
+ Assert.True(featured.IsVisible);
+ }
+
+ [AvaloniaFact]
+ public void VisibleWhileInstalling()
+ {
+ var featured = Create();
+ var target = PackageTestFactory.CreateViewModel("OneWare.AI", "ONE AI", PackageStatus.Available);
+ featured.Target = target;
+
+ ((FakeState)target.PackageState).Status = PackageStatus.Installing;
+
+ Assert.True(featured.IsVisible);
+ }
+
+ [AvaloniaFact]
+ public void HiddenOnceInstalled()
+ {
+ var featured = Create();
+ var target = PackageTestFactory.CreateViewModel("OneWare.AI", "ONE AI", PackageStatus.Available);
+ featured.Target = target;
+
+ var raised = 0;
+ featured.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(FeaturedPackageViewModel.IsVisible)) raised++;
+ };
+
+ ((FakeState)target.PackageState).Status = PackageStatus.Installed;
+
+ Assert.False(featured.IsVisible);
+ Assert.True(raised > 0);
+ }
+
+ [AvaloniaFact]
+ public void SwappingTargetDetachesTheOldHandler()
+ {
+ var featured = Create();
+ var stale = PackageTestFactory.CreateViewModel("OneWare.AI", "ONE AI", PackageStatus.Available);
+ featured.Target = stale;
+
+ var current = PackageTestFactory.CreateViewModel("OneWare.AI", "ONE AI", PackageStatus.Installed);
+ featured.Target = current;
+
+ var raised = 0;
+ featured.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(FeaturedPackageViewModel.IsVisible)) raised++;
+ };
+
+ // the orphaned view model must no longer be able to change the banner
+ ((FakeState)stale.PackageState).Status = PackageStatus.Installing;
+
+ Assert.Equal(0, raised);
+ Assert.False(featured.IsVisible);
+ }
+
+ [AvaloniaFact]
+ public async Task PrimaryCommandResolvesTabsBeforeRunningTheAction()
+ {
+ var featured = Create();
+ var target = PackageTestFactory.CreateViewModel("OneWare.AI", "ONE AI", PackageStatus.Available);
+ featured.Target = target;
+
+ var executed = false;
+ target.MainButtonCommand = new RelayCommand(() => executed = true);
+
+ await featured.PrimaryCommand.ExecuteAsync(null);
+
+ // the banner never selects the row, so it has to resolve the tabs the install flow depends on
+ Assert.True(target.IsTabsResolved);
+ Assert.True(executed);
+ }
+
+ [AvaloniaFact]
+ public async Task PrimaryCommandIsANoOpWithoutTarget()
+ {
+ await Create().PrimaryCommand.ExecuteAsync(null);
+ }
+
+ [AvaloniaFact]
+ public void DisposeDetachesTheTarget() {
+ var featured = Create();
+ var target = PackageTestFactory.CreateViewModel("OneWare.AI", "ONE AI", PackageStatus.Available);
+ featured.Target = target;
+
+ featured.Dispose();
+
+ var raised = 0;
+ featured.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(FeaturedPackageViewModel.IsVisible)) raised++;
+ };
+
+ ((FakeState)target.PackageState).Status = PackageStatus.Installed;
+
+ Assert.Equal(0, raised);
+ }
+}
diff --git a/tests/OneWare.PackageManager.UnitTests/ObservableCollectionReconcilerTests.cs b/tests/OneWare.PackageManager.UnitTests/ObservableCollectionReconcilerTests.cs
new file mode 100644
index 00000000..11fd6026
--- /dev/null
+++ b/tests/OneWare.PackageManager.UnitTests/ObservableCollectionReconcilerTests.cs
@@ -0,0 +1,99 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using OneWare.PackageManager.ViewModels;
+using Xunit;
+
+namespace OneWare.PackageManager.UnitTests;
+
+public class ObservableCollectionReconcilerTests
+{
+ private static (ObservableCollection Collection, List Actions) Track(
+ params string[] initial)
+ {
+ var collection = new ObservableCollection(initial);
+ var actions = new List();
+ collection.CollectionChanged += (_, e) => actions.Add(e.Action);
+ return (collection, actions);
+ }
+
+ [Fact]
+ public void Reconcile_WithIdenticalContent_DoesNotNotify()
+ {
+ var (collection, actions) = Track("a", "b", "c");
+
+ ObservableCollectionReconciler.Reconcile(collection, ["a", "b", "c"]);
+
+ Assert.Equal(["a", "b", "c"], collection);
+ Assert.Empty(actions);
+ }
+
+ [Fact]
+ public void Reconcile_WithInsertionsAndRemovals_KeepsOrderWithoutReset()
+ {
+ var (collection, actions) = Track("a", "c", "e");
+
+ ObservableCollectionReconciler.Reconcile(collection, ["a", "b", "c", "d"]);
+
+ Assert.Equal(["a", "b", "c", "d"], collection);
+ Assert.DoesNotContain(NotifyCollectionChangedAction.Reset, actions);
+ Assert.Contains(NotifyCollectionChangedAction.Remove, actions);
+ Assert.Contains(NotifyCollectionChangedAction.Add, actions);
+ }
+
+ [Fact]
+ public void Reconcile_WithOnlyRemovals_DoesNotReset()
+ {
+ var (collection, actions) = Track("a", "b", "c");
+
+ ObservableCollectionReconciler.Reconcile(collection, ["a", "c"]);
+
+ Assert.Equal(["a", "c"], collection);
+ Assert.All(actions, x => Assert.Equal(NotifyCollectionChangedAction.Remove, x));
+ }
+
+ [Fact]
+ public void Reconcile_WithReorder_FallsBackToReset()
+ {
+ var (collection, actions) = Track("a", "b", "c");
+
+ ObservableCollectionReconciler.Reconcile(collection, ["c", "b", "a"]);
+
+ Assert.Equal(["c", "b", "a"], collection);
+ Assert.Contains(NotifyCollectionChangedAction.Reset, actions);
+ }
+
+ [Fact]
+ public void Reconcile_WithDuplicateDesiredItems_FallsBackToResetAndStaysCorrect()
+ {
+ var (collection, actions) = Track();
+
+ ObservableCollectionReconciler.Reconcile(collection, ["a", "b", "a"]);
+ Assert.Equal(["a", "b", "a"], collection);
+
+ actions.Clear();
+
+ // A second identical pass must not corrupt the content.
+ ObservableCollectionReconciler.Reconcile(collection, ["a", "b", "a"]);
+ Assert.Equal(["a", "b", "a"], collection);
+ }
+
+ [Fact]
+ public void Reconcile_WithEmptyTarget_AddsEverything()
+ {
+ var collection = new ObservableCollection();
+
+ ObservableCollectionReconciler.Reconcile(collection, ["a", "b"]);
+
+ Assert.Equal(["a", "b"], collection);
+ }
+
+ [Fact]
+ public void Reconcile_WithEmptyDesired_ClearsCollection()
+ {
+ var collection = new ObservableCollection { "a", "b" };
+
+ ObservableCollectionReconciler.Reconcile(collection, []);
+
+ Assert.Empty(collection);
+ }
+}
diff --git a/tests/OneWare.PackageManager.UnitTests/OneWare.PackageManager.UnitTests.csproj b/tests/OneWare.PackageManager.UnitTests/OneWare.PackageManager.UnitTests.csproj
index 3f9a56c4..b095b8bd 100644
--- a/tests/OneWare.PackageManager.UnitTests/OneWare.PackageManager.UnitTests.csproj
+++ b/tests/OneWare.PackageManager.UnitTests/OneWare.PackageManager.UnitTests.csproj
@@ -1,6 +1,9 @@
+
+
+
net10.0
diff --git a/tests/OneWare.PackageManager.UnitTests/PackageListBehaviorTests.cs b/tests/OneWare.PackageManager.UnitTests/PackageListBehaviorTests.cs
new file mode 100644
index 00000000..8fdfab3d
--- /dev/null
+++ b/tests/OneWare.PackageManager.UnitTests/PackageListBehaviorTests.cs
@@ -0,0 +1,148 @@
+using Avalonia.Headless.XUnit;
+using OneWare.Essentials.Enums;
+using OneWare.PackageManager.ViewModels;
+using Xunit;
+
+namespace OneWare.PackageManager.UnitTests;
+
+public class PackageListBehaviorTests
+{
+ private static PackageViewModel Vm(string id, string name, PackageStatus status)
+ {
+ return PackageTestFactory.CreateViewModel(id, name, status);
+ }
+
+ [AvaloniaFact]
+ public void OrderIsStableAndStatusIndependent()
+ {
+ var root = new PackageCategoryViewModel("All", PackageCategoryKind.Root);
+ var plugins = root.GetOrCreateSubCategory("Plugins");
+ var hardware = root.GetOrCreateSubCategory("Hardware");
+
+ var zeta = Vm("p.zeta", "Zeta", PackageStatus.Available);
+ var alpha = Vm("p.alpha", "Alpha", PackageStatus.UpdateAvailable);
+ var beta = Vm("h.beta", "Beta", PackageStatus.Installed);
+
+ plugins.Add(zeta);
+ plugins.Add(alpha);
+ hardware.Add(beta);
+
+ root.Relayout(PackageListQuery.Default);
+
+ var entries = root.VisibleEntries.Select(Describe).ToList();
+
+ // separators are categories, packages alphabetical within them
+ Assert.Equal(["#Plugins", "Alpha", "Zeta", "#Hardware", "Beta"], entries);
+
+ var before = root.VisibleEntries.ToList();
+
+ // simulate an install: status changes, nothing else
+ ((FakeState)alpha.PackageState).Status = PackageStatus.NeedRestart;
+ root.Resync();
+
+ Assert.Equal(before, root.VisibleEntries.ToList());
+ }
+
+ [AvaloniaFact]
+ public void SegmentFilterDoesNotYankRowsOnDataChange()
+ {
+ var root = new PackageCategoryViewModel("All", PackageCategoryKind.Root);
+ var plugins = root.GetOrCreateSubCategory("Plugins");
+
+ var a = Vm("a", "Aaa", PackageStatus.Available);
+ var b = Vm("b", "Bbb", PackageStatus.Available);
+ plugins.Add(a);
+ plugins.Add(b);
+
+ // user selects "Available"
+ root.Relayout(new PackageListQuery(string.Empty, false, true));
+ Assert.Equal(2, root.VisiblePackages.Count);
+
+ // user installs Aaa -> it is no longer "available" but must stay put
+ ((FakeState)a.PackageState).Status = PackageStatus.Installed;
+ root.Resync();
+ Assert.Equal(2, root.VisiblePackages.Count);
+ Assert.Same(a, root.VisiblePackages[0]);
+
+ // next user action drops it
+ root.Relayout(new PackageListQuery(string.Empty, false, true));
+ Assert.Single(root.VisiblePackages);
+ Assert.Same(b, root.VisiblePackages[0]);
+ }
+
+ [AvaloniaFact]
+ public void SearchMatchesCategoriesAndPackages()
+ {
+ var root = new PackageCategoryViewModel("All", PackageCategoryKind.Root);
+ var plugins = root.GetOrCreateSubCategory("Plugins");
+ var hardware = root.GetOrCreateSubCategory("Hardware");
+
+ plugins.Add(Vm("p1", "Verilog", PackageStatus.Available));
+ hardware.Add(Vm("h1", "Cyclone", PackageStatus.Available));
+
+ // package name match
+ root.Relayout(new PackageListQuery("veri", true, true));
+ Assert.True(plugins.IsVisible);
+ Assert.False(hardware.IsVisible);
+ Assert.Single(root.VisiblePackages);
+
+ // category header match shows all of its packages
+ root.Relayout(new PackageListQuery("hardw", true, true));
+ Assert.True(hardware.IsVisible);
+ Assert.False(plugins.IsVisible);
+ Assert.Single(root.VisiblePackages);
+ Assert.Equal("Cyclone", root.VisiblePackages[0].PackageState.Package.Name);
+
+ // root is never hidden
+ root.Relayout(new PackageListQuery("zzzz", true, true));
+ Assert.True(root.IsVisible);
+ Assert.Empty(root.VisiblePackages);
+ }
+
+ [AvaloniaFact]
+ public void SearchRanksExactAndPrefixFirst()
+ {
+ var root = new PackageCategoryViewModel("All", PackageCategoryKind.Root);
+ root.Add(Vm("a", "My Quartus Helper", PackageStatus.Available));
+ root.Add(Vm("b", "Quartus", PackageStatus.Available));
+ root.Add(Vm("c", "Quartus Prime", PackageStatus.Available));
+
+ root.Relayout(new PackageListQuery("quartus", true, true));
+
+ Assert.Equal(["Quartus", "Quartus Prime", "My Quartus Helper"],
+ root.VisiblePackages.Select(x => x.PackageState.Package.Name));
+ }
+
+ [AvaloniaFact]
+ public void OwnGroupAndSubCategoryWithSameLabelStayStable()
+ {
+ var plugins = new PackageCategoryViewModel("Plugins");
+ var other = plugins.GetOrCreateSubCategory("Other");
+
+ plugins.Add(Vm("direct", "Direct", PackageStatus.Available));
+ other.Add(Vm("nested", "Nested", PackageStatus.Available));
+
+ plugins.Relayout(PackageListQuery.Default);
+
+ var separators = plugins.VisibleEntries.OfType().ToList();
+ Assert.Equal(2, separators.Count);
+ Assert.NotSame(separators[0], separators[1]);
+
+ var before = plugins.VisibleEntries.ToList();
+
+ plugins.Resync();
+
+ // No reset, the entries must be the very same instances in the very same order.
+ Assert.Equal(before, plugins.VisibleEntries.ToList());
+ }
+
+ private static string Describe(PackageListEntryViewModel entry)
+ {
+ return entry switch
+ {
+ PackageSeparatorViewModel s => "#" + s.Text,
+ PackageViewModel p => p.PackageState.Package.Name!,
+ _ => "?"
+ };
+ }
+}
diff --git a/tests/OneWare.PackageManager.UnitTests/PackageListComparerTests.cs b/tests/OneWare.PackageManager.UnitTests/PackageListComparerTests.cs
new file mode 100644
index 00000000..d2dd839f
--- /dev/null
+++ b/tests/OneWare.PackageManager.UnitTests/PackageListComparerTests.cs
@@ -0,0 +1,38 @@
+using OneWare.PackageManager.ViewModels;
+using Xunit;
+
+namespace OneWare.PackageManager.UnitTests;
+
+public class PackageListComparerTests
+{
+ [Fact]
+ public void GetRelevance_WithoutFilter_IsAlwaysEqual()
+ {
+ Assert.Equal(0, PackageListComparer.GetRelevance("Anything", string.Empty));
+ Assert.Equal(0, PackageListComparer.GetRelevance(null, string.Empty));
+ }
+
+ [Theory]
+ [InlineData("Quartus", "quartus", 0)]
+ [InlineData("Quartus Prime", "quartus", 1)]
+ [InlineData("Intel Quartus", "quartus", 2)]
+ [InlineData("Vivado", "quartus", 3)]
+ public void GetRelevance_RanksExactBeforePrefixBeforeContains(string name, string filter, int expected)
+ {
+ Assert.Equal(expected, PackageListComparer.GetRelevance(name, filter));
+ }
+
+ [Fact]
+ public void GetRelevance_IsCaseInsensitive()
+ {
+ Assert.Equal(0, PackageListComparer.GetRelevance("QUARTUS", "quartus"));
+ Assert.Equal(1, PackageListComparer.GetRelevance("quartus prime", "QUARTUS"));
+ }
+
+ [Fact]
+ public void GetRelevance_WithMissingName_RanksLast()
+ {
+ Assert.Equal(3, PackageListComparer.GetRelevance(null, "quartus"));
+ Assert.Equal(3, PackageListComparer.GetRelevance(string.Empty, "quartus"));
+ }
+}
diff --git a/tests/OneWare.PackageManager.UnitTests/PackageTestFactory.cs b/tests/OneWare.PackageManager.UnitTests/PackageTestFactory.cs
new file mode 100644
index 00000000..cb3cb5e2
--- /dev/null
+++ b/tests/OneWare.PackageManager.UnitTests/PackageTestFactory.cs
@@ -0,0 +1,51 @@
+using System.ComponentModel;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using OneWare.Essentials.Enums;
+using OneWare.Essentials.Models;
+using OneWare.Essentials.PackageManager;
+using OneWare.Essentials.Services;
+using OneWare.PackageManager.ViewModels;
+
+namespace OneWare.PackageManager.UnitTests;
+
+///
+/// Hand written because NSubstitute cannot stub the ResolveTargetVersion extension method and
+/// OneWare.PackageManager.Models.PackageState only exposes internal setters.
+///
+internal sealed class FakeState : INotifyPropertyChanged, IPackageState
+{
+ private PackageStatus _status;
+
+ public required Package Package { get; init; }
+ public PackageVersion? InstalledVersion { get; set; }
+ public string? InstalledVersionWarningText => null;
+ public bool IsIndeterminate => false;
+ public float Progress => 0;
+
+ public PackageStatus Status
+ {
+ get => _status;
+ set
+ {
+ _status = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Status)));
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+}
+
+internal static class PackageTestFactory
+{
+ public static PackageViewModel CreateViewModel(string id, string name, PackageStatus status)
+ {
+ var pkg = new Package { Id = id, Name = name, Versions = [new PackageVersion { Version = "1.0.0" }] };
+ var state = new FakeState { Package = pkg, Status = status };
+ if (status != PackageStatus.Available) state.InstalledVersion = pkg.Versions![0];
+
+ return new PackageViewModel(state, Substitute.For(), Substitute.For(),
+ Substitute.For(), Substitute.For(),
+ Substitute.For());
+ }
+}
diff --git a/tests/OneWare.PackageManager.UnitTests/TestApp.cs b/tests/OneWare.PackageManager.UnitTests/TestApp.cs
new file mode 100644
index 00000000..31fb8a22
--- /dev/null
+++ b/tests/OneWare.PackageManager.UnitTests/TestApp.cs
@@ -0,0 +1,28 @@
+using Avalonia;
+using Avalonia.Headless;
+using Avalonia.Themes.Simple;
+using OneWare.PackageManager.UnitTests;
+
+[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
+
+namespace OneWare.PackageManager.UnitTests;
+
+///
+/// Minimal application for the headless tests. The package view models resolve theme brushes from
+/// , so a running application is required.
+///
+public class TestApp : Application
+{
+ public override void Initialize()
+ {
+ Styles.Add(new SimpleTheme());
+ }
+}
+
+public static class TestAppBuilder
+{
+ public static AppBuilder BuildAvaloniaApp()
+ {
+ return AppBuilder.Configure().UseHeadless(new AvaloniaHeadlessPlatformOptions());
+ }
+}