diff --git a/src/OneWare.Essentials/Controls/SegmentedControl.axaml b/src/OneWare.Essentials/Controls/SegmentedControl.axaml new file mode 100644 index 000000000..da14375d4 --- /dev/null +++ b/src/OneWare.Essentials/Controls/SegmentedControl.axaml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Essentials/Controls/SegmentedControl.cs b/src/OneWare.Essentials/Controls/SegmentedControl.cs new file mode 100644 index 000000000..b19430b06 --- /dev/null +++ b/src/OneWare.Essentials/Controls/SegmentedControl.cs @@ -0,0 +1,70 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Mixins; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Layout; + +namespace OneWare.Essentials.Controls; + +/// +/// A generic single-selection segmented control that lays its items out horizontally. +/// Items can be plain objects or instances. +/// +public class SegmentedControl : SelectingItemsControl +{ + private static readonly FuncTemplate DefaultPanel = + new(() => new StackPanel { Orientation = Orientation.Horizontal }); + + static SegmentedControl() + { + SelectionModeProperty.OverrideDefaultValue( + SelectionMode.Single | SelectionMode.AlwaysSelected); + ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); + } + + protected override Type StyleKeyOverride => typeof(SegmentedControl); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + if (e.Source is Visual source && e.GetCurrentPoint(source).Properties.IsLeftButtonPressed) + e.Handled = UpdateSelectionFromEventSource(e.Source); + } + + protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new SegmentedControlItem(); + } + + protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } +} + +/// +/// Container for an item hosted inside a . +/// +public class SegmentedControlItem : ContentControl, ISelectable +{ + public static readonly StyledProperty IsSelectedProperty = + SelectingItemsControl.IsSelectedProperty.AddOwner(); + + static SegmentedControlItem() + { + SelectableMixin.Attach(IsSelectedProperty); + PressedMixin.Attach(); + FocusableProperty.OverrideDefaultValue(true); + } + + public bool IsSelected + { + get => GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + protected override Type StyleKeyOverride => typeof(SegmentedControlItem); +} diff --git a/src/OneWare.Essentials/Controls/SharedControls.axaml b/src/OneWare.Essentials/Controls/SharedControls.axaml index 661ef1aba..40bd6c68f 100644 --- a/src/OneWare.Essentials/Controls/SharedControls.axaml +++ b/src/OneWare.Essentials/Controls/SharedControls.axaml @@ -6,5 +6,6 @@ + \ No newline at end of file diff --git a/src/OneWare.Essentials/Services/IPackageService.cs b/src/OneWare.Essentials/Services/IPackageService.cs index 4110e1047..8dc386b2a 100644 --- a/src/OneWare.Essentials/Services/IPackageService.cs +++ b/src/OneWare.Essentials/Services/IPackageService.cs @@ -41,6 +41,13 @@ public interface IPackageService : INotifyPropertyChanged /// void RegisterPackageRepository(string url); + /// + /// Registers a package repository URL with fallback URLs. + /// Uses the first URL of the List that works. + /// + /// + void RegisterPackageRepository(List urls); + /// /// Registers a package installer for a package type. /// diff --git a/src/OneWare.PackageManager/Services/IPackageCatalog.cs b/src/OneWare.PackageManager/Services/IPackageCatalog.cs index 78a7b2815..422b62e34 100644 --- a/src/OneWare.PackageManager/Services/IPackageCatalog.cs +++ b/src/OneWare.PackageManager/Services/IPackageCatalog.cs @@ -6,7 +6,7 @@ public interface IPackageCatalog { IReadOnlyDictionary Manifests { get; } - Task RefreshAsync(IEnumerable sources, CancellationToken cancellationToken = default); + Task RefreshAsync(IEnumerable> sources, CancellationToken cancellationToken = default); void RegisterStandalone(Package package); } diff --git a/src/OneWare.PackageManager/Services/PackageCatalog.cs b/src/OneWare.PackageManager/Services/PackageCatalog.cs index 0602863d5..c89a0bb6e 100644 --- a/src/OneWare.PackageManager/Services/PackageCatalog.cs +++ b/src/OneWare.PackageManager/Services/PackageCatalog.cs @@ -26,27 +26,36 @@ public void RegisterStandalone(Package package) _manifests[package.Id] = package; } - public async Task RefreshAsync(IEnumerable sources, CancellationToken cancellationToken = default) + public async Task RefreshAsync(IEnumerable> sources, CancellationToken cancellationToken = default) { var result = true; var newPackages = new Dictionary(); - foreach (var source in sources) + foreach (var I in sources) { - IReadOnlyList loaded; - try - { - loaded = await _repositoryClient.LoadRepositoryAsync(source, cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) + + IReadOnlyList loaded = new List(); + foreach (var source in I) { - _logger.Error($"Failed to refresh package source '{source}'.", e); - result = false; - continue; + try + { + loaded = await _repositoryClient.LoadRepositoryAsync(source, cancellationToken); + if(loaded.Count == 0) + { + continue; + } + break; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + _logger.Error($"Failed to refresh package source '{source}'.", e); + result = false; + continue; + } } if (loaded.Count == 0) diff --git a/src/OneWare.PackageManager/Services/PackageService.cs b/src/OneWare.PackageManager/Services/PackageService.cs index d38f40b98..82a7920e0 100644 --- a/src/OneWare.PackageManager/Services/PackageService.cs +++ b/src/OneWare.PackageManager/Services/PackageService.cs @@ -31,7 +31,7 @@ public class PackageService : ObservableObject, IPackageService private readonly Dictionary _installersByType = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _activeInstalls = new(); private readonly Dictionary _installCancellation = new(); - private readonly List _repositoryUrls = []; + private readonly List> _repositoryUrls = []; private Task? _currentRefreshTask; @@ -96,7 +96,12 @@ public void RegisterPackage(Package package) public void RegisterPackageRepository(string url) { - _repositoryUrls.Add(url); + _repositoryUrls.Add(new List { url }); + } + + public void RegisterPackageRepository(List urls) + { + _repositoryUrls.Add(urls); } public void RegisterInstaller(string packageType) where T : IPackageInstaller @@ -299,8 +304,10 @@ private async Task RefreshInternalAsync() try { - var customRepositories = - _settingsService.GetSettingValue>("PackageManager_Sources"); + var customRepositories = _settingsService + .GetSettingValue>("PackageManager_Sources") + .Select(item => new List { item }) + .ToList(); var allRepos = _repositoryUrls.Concat(customRepositories); result = await _catalog.RefreshAsync(allRepos); diff --git a/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs index 13a1eb253..62173bc80 100644 --- a/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs +++ b/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using DynamicData; using OneWare.Essentials.Enums; @@ -27,6 +27,8 @@ public PackageViewModel? SelectedPackage public ObservableCollection VisiblePackages { get; } = []; + public ObservableCollection VisibleEntries { get; } = []; + public ObservableCollection SubCategories { get; } = []; public IconModel? IconModel { get; } = iconModel; @@ -42,25 +44,92 @@ public void Remove(PackageViewModel model) { Packages.Remove(model); VisiblePackages.Remove(model); + VisibleEntries.Remove(model); } - public void Filter(string filter, bool showInstalled, bool showAvailable, bool showUpdate) + public void Filter(string filter, bool showInstalled, bool showAvailable) { var filtered = Packages.Where(x => x.PackageState.Package.Name?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false); - if (!showInstalled) filtered = filtered.Where(x => x.PackageState.Status != PackageStatus.Installed); - if (!showAvailable) filtered = filtered.Where(x => x.PackageState.Status != PackageStatus.Available); - if (!showUpdate) filtered = filtered.Where(x => x.PackageState.Status != PackageStatus.UpdateAvailable); + if (!showInstalled) + filtered = filtered.Where(x => !IsInstalledPackage(x.PackageState.Status)); + + if (!showAvailable) + filtered = filtered.Where(x => IsInstalledPackage(x.PackageState.Status)); foreach (var subCategory in SubCategories) { - subCategory.Filter(filter, showInstalled, showAvailable, showUpdate); + subCategory.Filter(filter, showInstalled, showAvailable); filtered = filtered.Concat(subCategory.VisiblePackages); } + var orderedPackages = filtered + .OrderBy(GetPackageGroupPriority) + .ThenBy(x => x.PackageState.Package.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + VisiblePackages.Clear(); - VisiblePackages.AddRange(filtered.OrderBy(x => x.PackageState.Package.Name)); + VisiblePackages.AddRange(orderedPackages); + + VisibleEntries.Clear(); + VisibleEntries.AddRange(CreateVisibleEntries(orderedPackages)); + } + + private static int GetPackageGroupPriority(PackageViewModel package) + { + return package.PackageState.Status switch + { + PackageStatus.UpdateAvailable => 0, + PackageStatus.UpdateAvailablePrerelease => 0, + PackageStatus.Installed => 1, + PackageStatus.NeedRestart => 1, + PackageStatus.Installing => 1, + _ => 2 + }; + } + + private static bool IsInstalledPackage(PackageStatus status) + { + return status is PackageStatus.Installed + or PackageStatus.UpdateAvailable + or PackageStatus.UpdateAvailablePrerelease + or PackageStatus.NeedRestart + or PackageStatus.Installing; + } + + private static IReadOnlyList CreateVisibleEntries(IReadOnlyList packages) + { + var groups = packages + .GroupBy(GetPackageGroupPriority) + .OrderBy(x => x.Key) + .ToList(); + + if (groups.Count <= 1) + return packages.Cast().ToList(); + + var entries = new List(packages.Count + groups.Count); + + for (var groupIndex = 0; groupIndex < groups.Count; groupIndex++) + { + var group = groups[groupIndex]; + entries.Add(new PackageSeparatorViewModel(GetGroupLabel(group.Key), groupIndex > 0)); + + foreach (var package in group) + entries.Add(package); + } + + return entries; + } + + private static string GetGroupLabel(int groupPriority) + { + return groupPriority switch + { + 0 => "Update Available", + 1 => "Installed", + _ => "Available" + }; } } diff --git a/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs index 5da8920b7..454cf6206 100644 --- a/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs +++ b/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs @@ -27,7 +27,7 @@ public class PackageManagerViewModel : FlexibleWindowViewModelBase, IPackageWind private bool _showAvailable = true; private bool _showInstalled = true; - private bool _showUpdate = true; + private int _selectedFilterIndex; public PackageManagerViewModel(IPackageService packageService, IHttpService httpService, ILogger logger, IWindowService windowService, @@ -77,22 +77,27 @@ public bool ShowInstalled } } - public bool ShowUpdate + public bool ShowAvailable { - get => _showUpdate; + get => _showAvailable; set { - SetProperty(ref _showUpdate, value); + SetProperty(ref _showAvailable, value); FilterPackages(); } } - public bool ShowAvailable + /// + /// Index of the segmented control filter: 0 = All, 1 = Installed only, 2 = Available only. + /// + public int SelectedFilterIndex { - get => _showAvailable; + get => _selectedFilterIndex; set { - SetProperty(ref _showAvailable, value); + SetProperty(ref _selectedFilterIndex, value); + _showInstalled = value is 0 or 1; + _showAvailable = value is 0 or 2; FilterPackages(); } } @@ -224,6 +229,14 @@ public async Task ShowAndUpdateAllAsync() return await UpdateAllAsync(); } + public async Task ResolveSelectedPackageTabsAsync() + { + if (SelectedCategory?.SelectedPackage == null) + return; + + await SelectedCategory.SelectedPackage.ResolveTabsAsync(); + } + private bool FocusCategory(string category, string? subcategory) { var categoryVm = PackageCategories @@ -294,7 +307,7 @@ private void ConstructPackageViewModels() private void FilterPackages() { foreach (var categoryModel in PackageCategories) - categoryModel.Filter(Filter, _showInstalled, _showAvailable, _showUpdate); + categoryModel.Filter(Filter, _showInstalled, _showAvailable); } public override bool OnWindowClosing(FlexibleWindow window) diff --git a/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs new file mode 100644 index 000000000..50fd6f395 --- /dev/null +++ b/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs @@ -0,0 +1,8 @@ +namespace OneWare.PackageManager.ViewModels; + +public sealed class PackageSeparatorViewModel(string text, bool showLine) +{ + public string Text { get; } = text; + + public bool ShowLine { get; } = showLine; +} diff --git a/src/OneWare.PackageManager/Views/PackageManagerView.axaml b/src/OneWare.PackageManager/Views/PackageManagerView.axaml index d00d25dda..d8e82f818 100644 --- a/src/OneWare.PackageManager/Views/PackageManagerView.axaml +++ b/src/OneWare.PackageManager/Views/PackageManagerView.axaml @@ -17,14 +17,15 @@ - + - + @@ -32,14 +33,14 @@ Command="{Binding RefreshPackagesAsync}"> - - - - - - - - + + + + + + + - + + - - - - - + + + + + + + - + + ItemsSource="{Binding SelectedCategory.VisibleEntries, FallbackValue={x:Null}}"> - + @@ -106,6 +114,20 @@ + + + + + + @@ -158,14 +180,14 @@ - + - - - + + diff --git a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs index 4e4e623f3..a6ee1abbf 100644 --- a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs +++ b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs @@ -143,8 +143,14 @@ protected override AvaloniaObject CreateShell() protected override async Task LoadContentAsync() { + var cloudHost = Services.Resolve() + .GetSettingValue(OneWareCloudIntegrationModule.OneWareCloudHostKey); Services.Resolve().RegisterPackageRepository( - "https://raw.githubusercontent.com/one-ware/OneWare.PublicPackages/main/oneware-packages.json"); + new List{ + $"{cloudHost}api/studio/packages", + "https://raw.githubusercontent.com/one-ware/OneWare.PublicPackages/main/oneware-packages.json" + } + ); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) {