From eea22dad5d3ab929576b4532c2970a01434fedbc Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:27:28 +0200 Subject: [PATCH 1/6] Improve Extensions Explorer --- .../Controls/SegmentedControl.axaml | 68 +++++++++++++++ .../Controls/SegmentedControl.cs | 70 ++++++++++++++++ .../Controls/SharedControls.axaml | 1 + .../Services/IPackageService.cs | 7 ++ .../Services/IPackageCatalog.cs | 2 +- .../Services/PackageCatalog.cs | 39 +++++---- .../Services/PackageService.cs | 15 +++- .../ViewModels/PackageCategoryViewModel.cs | 83 +++++++++++++++++-- .../ViewModels/PackageManagerViewModel.cs | 29 +++++-- .../ViewModels/PackageSeparatorViewModel.cs | 8 ++ .../Views/PackageManagerView.axaml | 72 ++++++++++------ .../DesktopStudioApp.cs | 8 +- 12 files changed, 341 insertions(+), 61 deletions(-) create mode 100644 src/OneWare.Essentials/Controls/SegmentedControl.axaml create mode 100644 src/OneWare.Essentials/Controls/SegmentedControl.cs create mode 100644 src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs 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) { From 77d2540ab24b99693bf73de4bbf1f28ca77407c2 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:24:31 +0200 Subject: [PATCH 2/6] Load more from the Package.json. --- .../PackageManager/Package.cs | 2 + .../PackageManager/PackageManifest.cs | 8 ++- .../Services/PackageRepositoryClient.cs | 64 ++++++++++++++---- .../Services/PackageService.cs | 66 +++++++++++++++++++ .../ViewModels/PackageManagerViewModel.cs | 20 +++++- .../Views/PackageManagerView.axaml | 4 +- 6 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/OneWare.Essentials/PackageManager/Package.cs b/src/OneWare.Essentials/PackageManager/Package.cs index 7c6a6d41f..406bb9ec5 100644 --- a/src/OneWare.Essentials/PackageManager/Package.cs +++ b/src/OneWare.Essentials/PackageManager/Package.cs @@ -19,6 +19,8 @@ public class Package public string? UrlLaunchIds { get; init; } public string? IconUrl { get; init; } + + public string? Icon { get; set; } public string? SourceUrl { get; init; } diff --git a/src/OneWare.Essentials/PackageManager/PackageManifest.cs b/src/OneWare.Essentials/PackageManager/PackageManifest.cs index acf71c79d..0cabd2bac 100644 --- a/src/OneWare.Essentials/PackageManager/PackageManifest.cs +++ b/src/OneWare.Essentials/PackageManager/PackageManifest.cs @@ -1,6 +1,12 @@ -namespace OneWare.Essentials.PackageManager; +using System.Text.Json; + +namespace OneWare.Essentials.PackageManager; public class PackageManifest { public string? ManifestUrl { get; init; } + + public JsonElement? Content { get; init; } + + public string? Icon { get; init; } } \ No newline at end of file diff --git a/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs b/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs index d38bdfe61..bd7fac533 100644 --- a/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs +++ b/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using OneWare.Essentials.PackageManager; using OneWare.Essentials.Services; @@ -29,13 +28,12 @@ public async Task> LoadRepositoryAsync(string url, { try { - var repositoryString = await _httpService.DownloadTextAsync(url, TimeSpan.FromSeconds(10)); + var repositoryString = await _httpService.DownloadTextAsync(url, TimeSpan.FromSeconds(10), cancellationToken); if (repositoryString == null) return Array.Empty(); - var trimmed = WhitespaceRegex().Replace(repositoryString, ""); var packages = new List(); - if (trimmed.StartsWith("{\"packages\":", StringComparison.OrdinalIgnoreCase)) + if (IsRepositoryJson(repositoryString)) { try { @@ -45,12 +43,7 @@ public async Task> LoadRepositoryAsync(string url, foreach (var manifest in repository.Packages) try { - if (manifest.ManifestUrl == null) continue; - - var downloadManifest = - await _httpService.DownloadTextAsync(manifest.ManifestUrl); - - var package = JsonSerializer.Deserialize(downloadManifest!, SerializerOptions); + var package = await LoadPackageManifestAsync(manifest, cancellationToken); if (package != null) packages.Add(package); } @@ -101,6 +94,53 @@ public async Task> LoadRepositoryAsync(string url, } } - [GeneratedRegex(@"\s+")] - private static partial Regex WhitespaceRegex(); + private async Task LoadPackageManifestAsync(PackageManifest manifest, + CancellationToken cancellationToken) + { + var manifestContent = GetManifestContent(manifest.Content); + + if (manifestContent == null) + { + if (string.IsNullOrWhiteSpace(manifest.ManifestUrl)) + return null; + + manifestContent = await _httpService.DownloadTextAsync(manifest.ManifestUrl, cancellationToken: cancellationToken); + } + + if (string.IsNullOrWhiteSpace(manifestContent)) + return null; + + var package = JsonSerializer.Deserialize(manifestContent, SerializerOptions); + + if (package != null && string.IsNullOrWhiteSpace(package.Icon) && !string.IsNullOrWhiteSpace(manifest.Icon)) + package.Icon = manifest.Icon; + + return package; + } + + private static bool IsRepositoryJson(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions + { + AllowTrailingCommas = true + }); + + return document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("packages", out var packages) + && packages.ValueKind == JsonValueKind.Array; + } + + private static string? GetManifestContent(JsonElement? content) + { + if (content is not { } contentElement) return null; + + return contentElement.ValueKind switch + { + JsonValueKind.String => string.IsNullOrWhiteSpace(contentElement.GetString()) + ? null + : contentElement.GetString(), + JsonValueKind.Object => contentElement.GetRawText(), + _ => null + }; + } } diff --git a/src/OneWare.PackageManager/Services/PackageService.cs b/src/OneWare.PackageManager/Services/PackageService.cs index 82a7920e0..c7f5e8371 100644 --- a/src/OneWare.PackageManager/Services/PackageService.cs +++ b/src/OneWare.PackageManager/Services/PackageService.cs @@ -1,7 +1,10 @@ using System; using System.Collections.ObjectModel; +using System.Text; using System.Threading; using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Svg.Skia; using CommunityToolkit.Mvvm.ComponentModel; using DynamicData; using Microsoft.Extensions.Logging; @@ -290,11 +293,74 @@ public Task CheckCompatibilityAsync(string packageId, Packa public async Task DownloadPackageIconAsync(Package package) { + if (!string.IsNullOrWhiteSpace(package.Icon)) + return CreateImageFromBase64(package.Icon, package.Id); + if (package.IconUrl == null) return null; return await _httpService.DownloadImageAsync(package.IconUrl); } + private IImage? CreateImageFromBase64(string icon, string? packageId) + { + try + { + var base64 = icon.Trim(); + var mediaType = string.Empty; + + if (base64.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var separatorIndex = base64.IndexOf(','); + if (separatorIndex < 0) + { + _logger.Warning($"Invalid embedded icon data URI for package {packageId}."); + return null; + } + + mediaType = base64[..separatorIndex]; + base64 = base64[(separatorIndex + 1)..]; + } + + var bytes = Convert.FromBase64String(base64); + using var stream = new MemoryStream(bytes); + + if (IsSvgIcon(bytes, mediaType)) + { + var svg = SvgSource.LoadFromStream(stream); + return svg is null + ? null + : new SvgImage + { + Source = svg + }; + } + + return new Bitmap(stream); + } + catch (FormatException e) + { + _logger.Warning($"Invalid embedded icon base64 for package {packageId}: {e.Message}"); + } + catch (ArgumentException e) + { + _logger.Warning($"Invalid embedded icon for package {packageId}: {e.Message}"); + } + catch (NotSupportedException e) + { + _logger.Warning($"Unsupported embedded icon for package {packageId}: {e.Message}"); + } + + return null; + } + + private static bool IsSvgIcon(byte[] bytes, string mediaType) + { + if (mediaType.Contains("svg", StringComparison.OrdinalIgnoreCase)) return true; + + var prefix = Encoding.UTF8.GetString(bytes.AsSpan(0, Math.Min(bytes.Length, 512))); + return prefix.Contains(" RefreshInternalAsync() { await WaitForInstallsAsync(); diff --git a/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs index 454cf6206..c13277d0c 100644 --- a/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs +++ b/src/OneWare.PackageManager/ViewModels/PackageManagerViewModel.cs @@ -17,6 +17,7 @@ namespace OneWare.PackageManager.ViewModels; public class PackageManagerViewModel : FlexibleWindowViewModelBase, IPackageWindowService { + private const string AllCategoryHeader = "All"; private static readonly char[] CategorySeparators = ['/', '\\']; private readonly IApplicationStateService _applicationStateService; @@ -39,6 +40,7 @@ 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")); @@ -52,7 +54,7 @@ public PackageManagerViewModel(IPackageService packageService, IHttpService http RegisterCategory("Binaries/ONNX Runtimes"); RegisterCategory("Drivers", new IconModel("BoxIcons.RegularUsb")); - SelectedCategory = PackageCategories.First(); + SelectedCategory = GetAllCategory() ?? PackageCategories.FirstOrDefault(); _packageService.WhenValueChanged(x => x.IsUpdating).Subscribe(x => { IsLoading = x; }); @@ -259,8 +261,10 @@ private bool FocusCategory(string category, string? subcategory) private async Task FocusPluginAsync(string packageId) { - var categoryVm = - PackageCategories.FirstOrDefault(x => x.VisiblePackages.Any(x => x.PackageState.Package.Id == packageId)); + var categoryVm = PackageCategories + .Where(x => !x.Header.Equals(AllCategoryHeader, StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(x => x.VisiblePackages.Any(y => y.PackageState.Package.Id == packageId)) + ?? GetAllCategory(); if (categoryVm != null && _packageService.Packages.TryGetValue(packageId, out var packageModel)) { @@ -282,6 +286,8 @@ private bool FocusCategory(string category, string? subcategory) private void ConstructPackageViewModels() { + var allCategory = GetAllCategory(); + foreach (var category in PackageCategories) ClearCategoryPackages(category); @@ -294,6 +300,9 @@ private void ConstructPackageViewModels() var targetCategory = ResolveCategoryForPackage(packageModel.Package); if (targetCategory == null) continue; + if (allCategory != null && !ReferenceEquals(allCategory, targetCategory)) + allCategory.Add(viewModel); + targetCategory.Add(viewModel); } catch (Exception e) @@ -516,6 +525,11 @@ private static PackageCategoryViewModel GetOrCreateCategory( return FindCategory(PackageCategories, rootCategoryName); } + private PackageCategoryViewModel? GetAllCategory() + { + return FindCategory(PackageCategories, AllCategoryHeader); + } + private static void ClearCategoryPackages(PackageCategoryViewModel category) { foreach (var pkg in category.Packages.ToArray()) diff --git a/src/OneWare.PackageManager/Views/PackageManagerView.axaml b/src/OneWare.PackageManager/Views/PackageManagerView.axaml index d8e82f818..3df066a54 100644 --- a/src/OneWare.PackageManager/Views/PackageManagerView.axaml +++ b/src/OneWare.PackageManager/Views/PackageManagerView.axaml @@ -101,7 +101,7 @@ @@ -187,7 +187,7 @@ Background="{DynamicResource ThemeBackgroundBrush}" HorizontalAlignment="Center" /> - + From c6156895d5483983db2da7ac5d1058ddf08845a6 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 9 Jul 2026 10:04:07 +0200 Subject: [PATCH 3/6] small adjustments --- .../Extensions/StringExtensions.cs | 2 +- .../Services/IPackageService.cs | 4 ++-- .../Services/PackageService.cs | 8 ++++---- .../OneWare.Studio.Desktop/DesktopStudioApp.cs | 16 ++++++++-------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/OneWare.Essentials/Extensions/StringExtensions.cs b/src/OneWare.Essentials/Extensions/StringExtensions.cs index 6b59f355f..53edebb6d 100644 --- a/src/OneWare.Essentials/Extensions/StringExtensions.cs +++ b/src/OneWare.Essentials/Extensions/StringExtensions.cs @@ -35,7 +35,7 @@ public static bool EqualUrls(this string url1, string url2) StringComparison.OrdinalIgnoreCase) && string.Equals(uri1.Query, uri2.Query, StringComparison.Ordinal); } - + public static bool ContainsSubPath(this string pathToFile, string subPath) { pathToFile = Path.GetDirectoryName(pathToFile) + "\\"; diff --git a/src/OneWare.Essentials/Services/IPackageService.cs b/src/OneWare.Essentials/Services/IPackageService.cs index 8dc386b2a..cd869e84c 100644 --- a/src/OneWare.Essentials/Services/IPackageService.cs +++ b/src/OneWare.Essentials/Services/IPackageService.cs @@ -43,10 +43,10 @@ public interface IPackageService : INotifyPropertyChanged /// /// Registers a package repository URL with fallback URLs. - /// Uses the first URL of the List that works. + /// Uses the first URL of the array that works. /// /// - void RegisterPackageRepository(List urls); + void RegisterPackageRepositoryWithFallback(string[] urls); /// /// Registers a package installer for a package type. diff --git a/src/OneWare.PackageManager/Services/PackageService.cs b/src/OneWare.PackageManager/Services/PackageService.cs index c7f5e8371..9e322b025 100644 --- a/src/OneWare.PackageManager/Services/PackageService.cs +++ b/src/OneWare.PackageManager/Services/PackageService.cs @@ -34,7 +34,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; @@ -99,10 +99,10 @@ public void RegisterPackage(Package package) public void RegisterPackageRepository(string url) { - _repositoryUrls.Add(new List { url }); + _repositoryUrls.Add([url]); } - public void RegisterPackageRepository(List urls) + public void RegisterPackageRepositoryWithFallback(string[] urls) { _repositoryUrls.Add(urls); } @@ -372,7 +372,7 @@ private async Task RefreshInternalAsync() { var customRepositories = _settingsService .GetSettingValue>("PackageManager_Sources") - .Select(item => new List { item }) + .Select(item => new[] { item }) .ToList(); var allRepos = _repositoryUrls.Concat(customRepositories); diff --git a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs index a6ee1abbf..6411b4319 100644 --- a/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs +++ b/studio/OneWare.Studio.Desktop/DesktopStudioApp.cs @@ -145,11 +145,11 @@ protected override async Task LoadContentAsync() { var cloudHost = Services.Resolve() .GetSettingValue(OneWareCloudIntegrationModule.OneWareCloudHostKey); - Services.Resolve().RegisterPackageRepository( - new List{ - $"{cloudHost}api/studio/packages", + Services.Resolve().RegisterPackageRepositoryWithFallback( + [ + new Uri(new Uri(cloudHost), "api/studio/packages").AbsoluteUri, "https://raw.githubusercontent.com/one-ware/OneWare.PublicPackages/main/oneware-packages.json" - } + ] ); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime) @@ -234,7 +234,7 @@ protected override async Task LoadContentAsync() .ShowExtensionManagerAsync(updatePackage.Package!.Id!)) }); } - + var packageCount = updatePackages.Count; var packageLabel = packageCount == 1 ? "package update" : "package updates"; @@ -242,10 +242,10 @@ protected override async Task LoadContentAsync() $"{packageCount} {packageLabel} available.", "Update all", () => _ = Services.Resolve().ShowAndUpdateAllAsync()); } - + //Ask to install the OneWare.AI extension if (showOneWareAiNotification && Environment.GetEnvironmentVariable("ONEWARE_OPEN_URL") == null && - Environment.GetEnvironmentVariable("ONEWARE_AUTOLAUNCH") == null) + Environment.GetEnvironmentVariable("ONEWARE_AUTOLAUNCH") == null) { var aiReleaseWindowVm = Services.Resolve(); //check if the specified extension is already installed @@ -267,4 +267,4 @@ protected override async Task LoadContentAsync() Services.Resolve().Error(e.Message, e); } } -} +} \ No newline at end of file From a538d7761258a07656390584a5367d9220a64294 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:12:28 +0200 Subject: [PATCH 4/6] Make divider not clickable. --- .../ViewModels/PackageCategoryViewModel.cs | 8 ++++---- .../ViewModels/PackageListEntryViewModel.cs | 8 ++++++++ .../ViewModels/PackageSeparatorViewModel.cs | 4 +++- .../ViewModels/PackageViewModel.cs | 2 +- .../Views/PackageManagerView.axaml | 15 ++++++++++++--- 5 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/OneWare.PackageManager/ViewModels/PackageListEntryViewModel.cs diff --git a/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs index 62173bc80..e8980d5d6 100644 --- a/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs +++ b/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs @@ -27,7 +27,7 @@ public PackageViewModel? SelectedPackage public ObservableCollection VisiblePackages { get; } = []; - public ObservableCollection VisibleEntries { get; } = []; + public ObservableCollection VisibleEntries { get; } = []; public ObservableCollection SubCategories { get; } = []; @@ -99,7 +99,7 @@ or PackageStatus.NeedRestart or PackageStatus.Installing; } - private static IReadOnlyList CreateVisibleEntries(IReadOnlyList packages) + private static IReadOnlyList CreateVisibleEntries(IReadOnlyList packages) { var groups = packages .GroupBy(GetPackageGroupPriority) @@ -107,9 +107,9 @@ private static IReadOnlyList CreateVisibleEntries(IReadOnlyList().ToList(); + return packages.Cast().ToList(); - var entries = new List(packages.Count + groups.Count); + var entries = new List(packages.Count + groups.Count); for (var groupIndex = 0; groupIndex < groups.Count; groupIndex++) { diff --git a/src/OneWare.PackageManager/ViewModels/PackageListEntryViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageListEntryViewModel.cs new file mode 100644 index 000000000..7be58f869 --- /dev/null +++ b/src/OneWare.PackageManager/ViewModels/PackageListEntryViewModel.cs @@ -0,0 +1,8 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace OneWare.PackageManager.ViewModels; + +public abstract class PackageListEntryViewModel : ObservableObject +{ + public virtual bool IsSelectable => true; +} diff --git a/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs index 50fd6f395..c4386079b 100644 --- a/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs +++ b/src/OneWare.PackageManager/ViewModels/PackageSeparatorViewModel.cs @@ -1,8 +1,10 @@ namespace OneWare.PackageManager.ViewModels; -public sealed class PackageSeparatorViewModel(string text, bool showLine) +public sealed class PackageSeparatorViewModel(string text, bool showLine) : 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 457d836d6..c469233b2 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 : ObservableObject +public class PackageViewModel : PackageListEntryViewModel { private readonly IHttpService _httpService; private readonly IPackageService _packageService; diff --git a/src/OneWare.PackageManager/Views/PackageManagerView.axaml b/src/OneWare.PackageManager/Views/PackageManagerView.axaml index 3df066a54..e0d290d25 100644 --- a/src/OneWare.PackageManager/Views/PackageManagerView.axaml +++ b/src/OneWare.PackageManager/Views/PackageManagerView.axaml @@ -103,9 +103,18 @@ Background="Transparent" BorderThickness="0" SelectedItem="{Binding SelectedCategory.SelectedPackage, Mode=TwoWay, FallbackValue={x:Null}}" ItemsSource="{Binding SelectedCategory.VisibleEntries, FallbackValue={x:Null}}"> - - - + + + + + + + + + + From e4e3e98c13caf8947ccb1b01b035a7ba596f7a8a Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 9 Jul 2026 10:22:54 +0200 Subject: [PATCH 5/6] small adjustments --- src/OneWare.PackageManager/Services/IPackageCatalog.cs | 2 +- src/OneWare.PackageManager/Services/PackageCatalog.cs | 7 +++---- .../Services/PackageRepositoryClient.cs | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/OneWare.PackageManager/Services/IPackageCatalog.cs b/src/OneWare.PackageManager/Services/IPackageCatalog.cs index 422b62e34..41d25411b 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 repositories, CancellationToken cancellationToken = default); void RegisterStandalone(Package package); } diff --git a/src/OneWare.PackageManager/Services/PackageCatalog.cs b/src/OneWare.PackageManager/Services/PackageCatalog.cs index c89a0bb6e..12fd11e9f 100644 --- a/src/OneWare.PackageManager/Services/PackageCatalog.cs +++ b/src/OneWare.PackageManager/Services/PackageCatalog.cs @@ -26,16 +26,15 @@ public void RegisterStandalone(Package package) _manifests[package.Id] = package; } - public async Task RefreshAsync(IEnumerable> sources, CancellationToken cancellationToken = default) + public async Task RefreshAsync(IEnumerable repositories, CancellationToken cancellationToken = default) { var result = true; var newPackages = new Dictionary(); - foreach (var I in sources) + foreach (var repository in repositories) { - IReadOnlyList loaded = new List(); - foreach (var source in I) + foreach (var source in repository) { try { diff --git a/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs b/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs index bd7fac533..06dffcdf9 100644 --- a/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs +++ b/src/OneWare.PackageManager/Services/PackageRepositoryClient.cs @@ -90,7 +90,7 @@ public async Task> LoadRepositoryAsync(string url, catch (Exception e) { _logger.Error($"Failed to load package source '{url}'.", e); - return Array.Empty(); + return []; } } From ca2bae7a2e1d3ba345d1d49c4cd19f2b0ff94b40 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:32:50 +0200 Subject: [PATCH 6/6] Divider Quiq scroll --- .../ViewModels/PackageCategoryViewModel.cs | 5 + .../Views/PackageManagerView.axaml | 34 ++++--- .../Views/PackageManagerView.axaml.cs | 95 ++++++++++++++++++- 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs b/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs index e8980d5d6..6205f3eb2 100644 --- a/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs +++ b/src/OneWare.PackageManager/ViewModels/PackageCategoryViewModel.cs @@ -29,6 +29,8 @@ public PackageViewModel? SelectedPackage public ObservableCollection VisibleEntries { get; } = []; + public ObservableCollection VisibleSeparators { get; } = []; + public ObservableCollection SubCategories { get; } = []; public IconModel? IconModel { get; } = iconModel; @@ -75,6 +77,9 @@ public void Filter(string filter, bool showInstalled, bool showAvailable) VisibleEntries.Clear(); VisibleEntries.AddRange(CreateVisibleEntries(orderedPackages)); + + VisibleSeparators.Clear(); + VisibleSeparators.AddRange(VisibleEntries.OfType()); } private static int GetPackageGroupPriority(PackageViewModel package) diff --git a/src/OneWare.PackageManager/Views/PackageManagerView.axaml b/src/OneWare.PackageManager/Views/PackageManagerView.axaml index e0d290d25..a11f8d179 100644 --- a/src/OneWare.PackageManager/Views/PackageManagerView.axaml +++ b/src/OneWare.PackageManager/Views/PackageManagerView.axaml @@ -20,6 +20,13 @@ + @@ -108,7 +115,6 @@ x:DataType="viewModels:PackageListEntryViewModel" BasedOn="{StaticResource {x:Type ListBoxItem}}"> - @@ -124,18 +130,20 @@ - - - - + diff --git a/src/OneWare.PackageManager/Views/PackageManagerView.axaml.cs b/src/OneWare.PackageManager/Views/PackageManagerView.axaml.cs index c0e6e6ac5..9866dc35c 100644 --- a/src/OneWare.PackageManager/Views/PackageManagerView.axaml.cs +++ b/src/OneWare.PackageManager/Views/PackageManagerView.axaml.cs @@ -1,11 +1,104 @@ -using OneWare.Essentials.Controls; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; +using OneWare.Essentials.Controls; +using OneWare.PackageManager.ViewModels; namespace OneWare.PackageManager.Views; public partial class PackageManagerView : FlexibleWindow { + private PackageViewModel? _lastSelectedPackage; + private ScrollViewer? _pluginListScrollViewer; + public PackageManagerView() { InitializeComponent(); + + PluginList.SelectionChanged += PluginList_OnSelectionChanged; + } + + private void PackageSeparatorButton_OnClick(object? sender, RoutedEventArgs e) + { + if (sender is not Button button) + return; + + if (DataContext is not PackageManagerViewModel viewModel) + return; + + var separators = viewModel.SelectedCategory?.VisibleSeparators; + if (separators == null || separators.Count == 0) + return; + + var flyout = new MenuFlyout(); + + foreach (var separator in separators) + { + var menuItem = new MenuItem + { + Header = separator.Text + }; + + menuItem.Click += (_, _) => ScrollToSeparator(separator); + flyout.Items.Add(menuItem); + } + + flyout.ShowAt(button); + } + + private void PluginList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (PluginList.SelectedItem is PackageViewModel packageViewModel) + { + _lastSelectedPackage = packageViewModel; + return; + } + + if (PluginList.SelectedItem is not PackageSeparatorViewModel) + return; + + Dispatcher.UIThread.Post(() => PluginList.SelectedItem = _lastSelectedPackage, DispatcherPriority.Input); + } + + private void ScrollToSeparator(PackageSeparatorViewModel separator) + { + Dispatcher.UIThread.Post(() => + { + ScrollSeparatorContainerToTop(separator); + }, DispatcherPriority.Background); + } + + private void ScrollSeparatorContainerToTop(PackageSeparatorViewModel separator) + { + var separatorContainer = PluginList.ContainerFromItem(separator) as Control; + if (separatorContainer == null) + { + PluginList.ScrollIntoView(separator); + separatorContainer = PluginList.ContainerFromItem(separator) as Control; + } + + if (separatorContainer == null) + return; + + var scrollViewer = GetPluginListScrollViewer(); + if (scrollViewer == null) + { + separatorContainer.BringIntoView(); + return; + } + + var maxOffset = Math.Max(0, scrollViewer.Extent.Height - scrollViewer.Viewport.Height); + var targetOffset = Math.Clamp(separatorContainer.Bounds.Top, 0, maxOffset); + scrollViewer.Offset = scrollViewer.Offset.WithY(targetOffset); + } + + private ScrollViewer? GetPluginListScrollViewer() + { + if (_pluginListScrollViewer != null) + return _pluginListScrollViewer; + + _pluginListScrollViewer = PluginList.GetVisualDescendants().OfType().FirstOrDefault(); + return _pluginListScrollViewer; } } \ No newline at end of file