From bbb806f76f9227aafc1d6ce81754649288818508 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 17 Sep 2026 15:01:07 +0200 Subject: [PATCH] improve package-manger UX --- src/OneWare.Copilot/CopilotModule.cs | 3 +- .../ViewModels/FeaturedPackageViewModel.cs | 95 +++++++ .../ObservableCollectionReconciler.cs | 69 +++++ .../ViewModels/PackageCategoryKind.cs | 16 ++ .../ViewModels/PackageCategoryViewModel.cs | 265 ++++++++++++----- .../ViewModels/PackageListComparer.cs | 41 +++ .../ViewModels/PackageListQuery.cs | 12 + .../ViewModels/PackageManagerViewModel.cs | 268 ++++++++++++------ .../ViewModels/PackageSeparatorViewModel.cs | 4 +- .../ViewModels/PackageViewModel.cs | 66 ++++- .../Views/PackageManagerView.axaml | 111 +++++--- .../Views/PackageManagerView.axaml.cs | 5 +- .../Views/PackageView.axaml | 18 +- src/OneWare.TypeScript/TypeScriptModule.cs | 3 +- .../FeaturedPackageViewModelTests.cs | 127 +++++++++ .../ObservableCollectionReconcilerTests.cs | 99 +++++++ .../OneWare.PackageManager.UnitTests.csproj | 3 + .../PackageListBehaviorTests.cs | 148 ++++++++++ .../PackageListComparerTests.cs | 38 +++ .../PackageTestFactory.cs | 51 ++++ .../TestApp.cs | 28 ++ 21 files changed, 1265 insertions(+), 205 deletions(-) create mode 100644 src/OneWare.PackageManager/ViewModels/FeaturedPackageViewModel.cs create mode 100644 src/OneWare.PackageManager/ViewModels/ObservableCollectionReconciler.cs create mode 100644 src/OneWare.PackageManager/ViewModels/PackageCategoryKind.cs create mode 100644 src/OneWare.PackageManager/ViewModels/PackageListComparer.cs create mode 100644 src/OneWare.PackageManager/ViewModels/PackageListQuery.cs create mode 100644 tests/OneWare.PackageManager.UnitTests/FeaturedPackageViewModelTests.cs create mode 100644 tests/OneWare.PackageManager.UnitTests/ObservableCollectionReconcilerTests.cs create mode 100644 tests/OneWare.PackageManager.UnitTests/PackageListBehaviorTests.cs create mode 100644 tests/OneWare.PackageManager.UnitTests/PackageListComparerTests.cs create mode 100644 tests/OneWare.PackageManager.UnitTests/PackageTestFactory.cs create mode 100644 tests/OneWare.PackageManager.UnitTests/TestApp.cs 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 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + +