diff --git a/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs b/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs index 449046de8..975d0ff30 100644 --- a/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs +++ b/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs @@ -62,12 +62,12 @@ public sealed class FakeNuGetFeedClient : INuGetFeedClient /// public TaskCompletionSource? PendingDownload { get; set; } - public static NuGetPackageInfo MakePackage(string id, string version = "1.0.0") => new( + public static NuGetPackageInfo MakePackage(string id, string version = "1.0.0", string? iconUrl = null) => new( Id: id, LatestVersion: version, Description: $"Description of {id}", Authors: "Test Author", - IconUrl: null, + IconUrl: iconUrl, LicenseUrl: "https://licenses.example/MIT", ProjectUrl: $"https://github.example/{id}", Tags: "test fake", diff --git a/ILSpy.Tests/NuGetFeeds/FakeNuGetIconLoader.cs b/ILSpy.Tests/NuGetFeeds/FakeNuGetIconLoader.cs new file mode 100644 index 000000000..294ce9527 --- /dev/null +++ b/ILSpy.Tests/NuGetFeeds/FakeNuGetIconLoader.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Media; + +using ICSharpCode.ILSpy.NuGetFeeds; + +namespace ICSharpCode.ILSpy.Tests.NuGetFeeds; + +/// +/// In-memory : records every requested URL and returns a distinct +/// placeholder image per URL (or null to model a missing/broken icon), so tests can assert that a +/// package's icon was fetched and swapped in without touching the network. +/// +public sealed class FakeNuGetIconLoader : INuGetIconLoader +{ + public ConcurrentBag RequestedUrls { get; } = new(); + + /// When false, every load resolves to null (models a broken/missing icon). + public bool ReturnImage { get; set; } = true; + + /// + /// When set, loads do not complete until this source is resolved (or the call's token fires), + /// letting a test hold icon loads in flight and then cancel them with a new search. + /// + public TaskCompletionSource? Gate { get; set; } + + public Task LoadIconAsync(string iconUrl, CancellationToken cancellationToken) + { + RequestedUrls.Add(iconUrl); + if (cancellationToken.IsCancellationRequested) + return Task.FromResult(null); + if (Gate != null) + return AwaitGateAsync(iconUrl, cancellationToken); + return Task.FromResult(Result(iconUrl)); + } + + async Task AwaitGateAsync(string iconUrl, CancellationToken cancellationToken) + { + // The contract is "cancellation yields a null icon", not an exception; model that so tests + // exercise the same path production takes when a search is superseded mid-load. + try + { + await Gate!.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + return null; + } + return Result(iconUrl); + } + + IImage? Result(string iconUrl) => ReturnImage ? new StubImage(iconUrl) : null; + + /// A trivial non-drawing that carries the URL it stands in for. + public sealed class StubImage(string url) : IImage + { + public string Url { get; } = url; + public Size Size => new(64, 64); + public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) { } + } +} diff --git a/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs b/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs index 321bfb6bc..b15e63b83 100644 --- a/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs +++ b/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs @@ -25,6 +25,7 @@ using Avalonia.Headless.NUnit; using AwesomeAssertions; +using ICSharpCode.ILSpy; using ICSharpCode.ILSpy.NuGetFeeds; using ICSharpCode.ILSpy.ViewModels; @@ -45,14 +46,21 @@ public class OpenFromNuGetFeedViewModelTests const string CustomFeed = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json"; static (OpenFromNuGetFeedDialogViewModel vm, FakeNuGetFeedClient client, NuGetFeedSettings settings) - CreateViewModel(TimeSpan? debounceDelay = null, NuGetFeedSettings? settings = null) + CreateViewModel(TimeSpan? debounceDelay = null, NuGetFeedSettings? settings = null, + INuGetIconLoader? iconLoader = null) { var client = new FakeNuGetFeedClient(); settings ??= new NuGetFeedSettings(); - var vm = new OpenFromNuGetFeedDialogViewModel(client, settings, debounceDelay ?? TimeSpan.Zero); + var vm = new OpenFromNuGetFeedDialogViewModel( + client, settings, debounceDelay ?? TimeSpan.Zero, iconLoader); return (vm, client, settings); } + // The list/selection now carry per-row view models; tests that drive a selection directly + // wrap the feed DTO the same way a real search result is wrapped. + static NuGetPackageViewModel Pkg(string id, string version = "1.0.0") + => new(FakeNuGetFeedClient.MakePackage(id, version)); + [AvaloniaTest] public async Task Refresh_Searches_The_Persisted_Feed_With_Empty_Term_And_Persisted_Prerelease() { @@ -146,7 +154,7 @@ public class OpenFromNuGetFeedViewModelTests client.SearchCalls[1].Skip.Should().Be(OpenFromNuGetFeedDialogViewModel.PageSize, "the second page must start where the first ended"); vm.CanLoadMore.Should().BeFalse("a short page means the listing is exhausted"); - vm.Packages.Select(p => p.Id).Should().OnlyHaveUniqueItems(); + vm.Packages.Select(p => p.Info.Id).Should().OnlyHaveUniqueItems(); } [AvaloniaTest] @@ -169,7 +177,7 @@ public class OpenFromNuGetFeedViewModelTests var (vm, client, _) = CreateViewModel(); client.VersionsToReturn = new[] { "13.0.3", "13.0.2", "12.0.1" }; - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + vm.SelectedPackage = Pkg("Newtonsoft.Json"); await Waiters.WaitForAsync(() => vm.Versions.Count == 3); vm.Versions.Should().Equal(new[] { "13.0.3", "13.0.2", "12.0.1" }, @@ -188,7 +196,7 @@ public class OpenFromNuGetFeedViewModelTests vm.IncludePrerelease = true; await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0); - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + vm.SelectedPackage = Pkg("Newtonsoft.Json"); await Waiters.WaitForAsync(() => client.VersionsCalls.Count > 0); client.VersionsCalls.Last().IncludePrerelease.Should().BeTrue(); @@ -198,11 +206,11 @@ public class OpenFromNuGetFeedViewModelTests public async Task Switching_Packages_Replaces_The_Version_List() { var (vm, client, _) = CreateViewModel(); - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("PackageA"); + vm.SelectedPackage = Pkg("PackageA"); await Waiters.WaitForAsync(() => vm.Versions.Count > 0); client.VersionsToReturn = new[] { "2.0.0" }; - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("PackageB"); + vm.SelectedPackage = Pkg("PackageB"); await Waiters.WaitForAsync(() => vm.Versions.Count == 1); vm.Versions.Should().Equal(new[] { "2.0.0" }); @@ -215,7 +223,7 @@ public class OpenFromNuGetFeedViewModelTests { var (vm, client, _) = CreateViewModel(); client.VersionsToReturn = new[] { "13.0.3", "13.0.2" }; - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + vm.SelectedPackage = Pkg("Newtonsoft.Json"); await Waiters.WaitForAsync(() => vm.SelectedVersion != null); // A ComboBox resets its SelectedItem to null when its items change under it; @@ -232,7 +240,7 @@ public class OpenFromNuGetFeedViewModelTests var (vm, client, _) = CreateViewModel(); client.VersionsException = new HttpRequestException("flat container unavailable"); - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + vm.SelectedPackage = Pkg("Newtonsoft.Json"); await Waiters.WaitForAsync(() => vm.ErrorMessage != null); vm.ErrorMessage.Should().Contain("flat container unavailable"); @@ -247,7 +255,7 @@ public class OpenFromNuGetFeedViewModelTests vm.OpenPackageCommand.CanExecute(null).Should().BeFalse( "there is nothing to download before a package/version is selected"); - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + vm.SelectedPackage = Pkg("Newtonsoft.Json"); await Waiters.WaitForAsync(() => vm.SelectedVersion != null); vm.OpenPackageCommand.CanExecute(null).Should().BeTrue(); @@ -260,7 +268,7 @@ public class OpenFromNuGetFeedViewModelTests string? closedWith = null; vm.CloseRequested += path => closedWith = path; - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + vm.SelectedPackage = Pkg("Newtonsoft.Json"); await Waiters.WaitForAsync(() => vm.SelectedVersion != null); vm.SelectedVersion = "13.0.2"; @@ -284,7 +292,7 @@ public class OpenFromNuGetFeedViewModelTests bool closeRaised = false; vm.CloseRequested += _ => closeRaised = true; - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + vm.SelectedPackage = Pkg("Newtonsoft.Json"); await Waiters.WaitForAsync(() => vm.SelectedVersion != null); vm.OpenPackageCommand.Execute(null); await Waiters.WaitForAsync(() => vm.IsDownloading); @@ -308,7 +316,7 @@ public class OpenFromNuGetFeedViewModelTests bool closeRaised = false; vm.CloseRequested += _ => closeRaised = true; - vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + vm.SelectedPackage = Pkg("Newtonsoft.Json"); await Waiters.WaitForAsync(() => vm.SelectedVersion != null); vm.OpenPackageCommand.Execute(null); await Waiters.WaitForAsync(() => vm.ErrorMessage != null); @@ -338,4 +346,82 @@ public class OpenFromNuGetFeedViewModelTests await Waiters.WaitForAsync(() => vm.Packages.Count > 0); vm.ErrorMessage.Should().BeNull("a successful retry must dismiss the stale error"); } + + [AvaloniaTest] + public async Task A_Result_With_An_Icon_Url_Has_Its_Custom_Icon_Fetched_And_Swapped_In() + { + var iconLoader = new FakeNuGetIconLoader(); + var (vm, client, _) = CreateViewModel(iconLoader: iconLoader); + client.SearchHandler = _ => new[] { + FakeNuGetFeedClient.MakePackage("WithIcon", iconUrl: "https://example/icon.png") + }; + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count > 0); + await Waiters.WaitForAsync(() => vm.Packages[0].Icon is FakeNuGetIconLoader.StubImage); + + ((FakeNuGetIconLoader.StubImage)vm.Packages[0].Icon).Url.Should().Be("https://example/icon.png", + "the row must show the package's own icon once it loads"); + iconLoader.RequestedUrls.Should().Contain("https://example/icon.png"); + } + + [AvaloniaTest] + public async Task A_Result_Without_An_Icon_Url_Keeps_The_Default_Icon_And_Is_Never_Fetched() + { + var iconLoader = new FakeNuGetIconLoader(); + var (vm, client, _) = CreateViewModel(iconLoader: iconLoader); + client.SearchHandler = _ => new[] { FakeNuGetFeedClient.MakePackage("NoIcon") }; + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count > 0); + // Give any erroneous load a chance to run before asserting it did not happen. + await Task.Delay(50); + + vm.Packages[0].Icon.Should().BeSameAs(Images.NuGet, "a package with no icon URL stays on the default"); + iconLoader.RequestedUrls.Should().BeEmpty("there is nothing to fetch without an icon URL"); + } + + [AvaloniaTest] + public async Task A_Broken_Icon_Url_Leaves_The_Default_Icon_In_Place() + { + var iconLoader = new FakeNuGetIconLoader { ReturnImage = false }; + var (vm, client, _) = CreateViewModel(iconLoader: iconLoader); + client.SearchHandler = _ => new[] { + FakeNuGetFeedClient.MakePackage("WithIcon", iconUrl: "https://example/icon.png") + }; + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count > 0); + await Waiters.WaitForAsync(() => iconLoader.RequestedUrls.Contains("https://example/icon.png")); + await Task.Delay(50); + + vm.Packages[0].Icon.Should().BeSameAs(Images.NuGet, "a failed fetch must not disturb the default icon"); + } + + [AvaloniaTest] + public async Task A_New_Search_Cancels_The_Previous_Result_Sets_Pending_Icon_Loads() + { + var gate = new TaskCompletionSource(); + var iconLoader = new FakeNuGetIconLoader { Gate = gate }; + var (vm, client, _) = CreateViewModel(iconLoader: iconLoader); + client.SearchHandler = _ => new[] { + FakeNuGetFeedClient.MakePackage("Pkg", iconUrl: "https://example/icon.png") + }; + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count > 0); + var firstRow = vm.Packages[0]; + // The first row's icon load is now in flight, parked on the gate. + await Waiters.WaitForAsync(() => iconLoader.RequestedUrls.Count > 0); + + // A new search replaces the result set, which must cancel the first set's pending load. + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count > 0 && !ReferenceEquals(vm.Packages[0], firstRow)); + + gate.SetResult(); + await Task.Delay(50); + + firstRow.Icon.Should().BeSameAs(Images.NuGet, + "a superseded result set must not paint icons onto its now-discarded rows"); + } } diff --git a/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs b/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs index ed3851641..921b86f71 100644 --- a/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs +++ b/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs @@ -21,6 +21,7 @@ using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Headless.NUnit; using Avalonia.VisualTree; @@ -82,6 +83,20 @@ public class OpenFromNuGetFeedDialogStructureTests errorBar!.IsVisible.Should().BeFalse("no error is showing before anything went wrong"); } + [AvaloniaTest] + public void Results_List_Disables_Horizontal_Scrolling() + { + // With horizontal scrolling enabled, rows are measured at their unbounded content width + // instead of the viewport width, so the star-sized column stops filling and selecting a + // row brings its overflow into view - the list jumps right and grows a scrollbar. The + // row layout is meant to fit the width (the description wraps/trims), so it must be off. + var list = CreateDialog().FindControl("PackagesList")!; + + ScrollViewer.GetHorizontalScrollBarVisibility(list) + .Should().Be(ScrollBarVisibility.Disabled, + "selecting a package must not scroll or shift the results list sideways"); + } + [AvaloniaTest] public async Task Feed_ComboBox_Is_Editable_So_Custom_Feed_Urls_Can_Be_Typed() { diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs index 8f2b961db..016671924 100644 --- a/ILSpy/Commands/FileCommands.cs +++ b/ILSpy/Commands/FileCommands.cs @@ -131,6 +131,8 @@ namespace ICSharpCode.ILSpy.Commands // One client for the command's lifetime so the per-feed service-index discovery // and the NuGet HTTP cache survive across dialog invocations. readonly NuGetFeedClient feedClient = new(); + // Likewise long-lived so the icon cache survives across dialog invocations. + readonly NuGetIconLoader iconLoader = new(); public override void Execute(object? parameter) { @@ -142,7 +144,7 @@ namespace ICSharpCode.ILSpy.Commands async Task ShowAsync(global::Avalonia.Controls.Window owner) { - var dlg = new Views.OpenFromNuGetFeedDialog(settingsService, feedClient); + var dlg = new Views.OpenFromNuGetFeedDialog(settingsService, feedClient, iconLoader: iconLoader); // The dialog result is the absolute path of the .nupkg in the global packages // folder; ILSpy opens .nupkg natively (ArchiveFileLoader/LoadedPackage). var path = await dlg.ShowDialog(owner); diff --git a/ILSpy/NuGetFeeds/INuGetIconLoader.cs b/ILSpy/NuGetFeeds/INuGetIconLoader.cs new file mode 100644 index 000000000..f1769ff31 --- /dev/null +++ b/ILSpy/NuGetFeeds/INuGetIconLoader.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Media; + +namespace ICSharpCode.ILSpy.NuGetFeeds +{ + /// + /// Fetches a package's custom icon for the "Open from NuGet feed" dialog. The production + /// implementation () downloads and decodes the image; tests + /// substitute a fake so no network is involved. + /// + public interface INuGetIconLoader + { + /// + /// Downloads and decodes the icon at . Returns + /// for anything that isn't a usable icon - a non-http(s) URL, a + /// network or decode failure, or cancellation - so callers simply keep the default icon. + /// + Task LoadIconAsync(string iconUrl, CancellationToken cancellationToken); + } +} diff --git a/ILSpy/NuGetFeeds/NuGetIconLoader.cs b/ILSpy/NuGetFeeds/NuGetIconLoader.cs new file mode 100644 index 000000000..019b3c830 --- /dev/null +++ b/ILSpy/NuGetFeeds/NuGetIconLoader.cs @@ -0,0 +1,98 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Media; +using Avalonia.Media.Imaging; + +namespace ICSharpCode.ILSpy.NuGetFeeds +{ + /// + /// Downloads package icons over HTTP and decodes them into Avalonia bitmaps. Results are + /// cached by URL for the loader's lifetime, so re-searching or re-displaying the same package + /// reuses the already-fetched icon and each URL is downloaded at most once. Icons are decoded + /// to a small fixed width since the dialog renders them at 32px. + /// + public sealed class NuGetIconLoader : INuGetIconLoader + { + // Rendered at 32px in the list and the detail header; 64px keeps it crisp on HiDPI + // without holding the often-large published icon (128px+) in memory per result. + const int DecodeWidth = 64; + + // Cap how much an icon response may buffer into memory; a broken or hostile feed could + // otherwise stream an unbounded payload. Package icons are tiny, so a few MB is generous + // and GetByteArrayAsync throws (caught below) once a response exceeds it. + const int MaxResponseBytes = 4 * 1024 * 1024; + + static readonly HttpClient httpClient = new() { + Timeout = TimeSpan.FromSeconds(15), + MaxResponseContentBufferSize = MaxResponseBytes + }; + + // The cached value is the in-flight (or completed) decode task, so concurrent requests for + // one URL share a single download instead of racing. A failed/missing icon caches as a + // completed null - we don't retry a dead URL within a session. + readonly ConcurrentDictionary> cache = + new(StringComparer.OrdinalIgnoreCase); + + public async Task LoadIconAsync(string iconUrl, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(iconUrl, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return null; + } + // One shared download per URL feeds every caller, so the cache dedups regardless of who + // asked. Each caller awaits it through its own token, though, so a superseded search + // stops waiting at once and gets null (per the interface contract) instead of hanging + // on the in-flight download until HttpClient.Timeout. + var download = cache.GetOrAdd(iconUrl, + static (url, client) => DownloadAndDecodeAsync(client, url), httpClient); + try + { + return await download.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return null; + } + } + + static async Task DownloadAndDecodeAsync(HttpClient client, string url) + { + try + { + byte[] bytes = await client.GetByteArrayAsync(url).ConfigureAwait(false); + using var stream = new MemoryStream(bytes); + return Bitmap.DecodeToWidth(stream, DecodeWidth); + } + catch (Exception) + { + // A broken or unreachable icon URL is not an error worth surfacing: the dialog + // just keeps showing the default NuGet icon for that package. + return null; + } + } + } +} diff --git a/ILSpy/ViewModels/NuGetPackageViewModel.cs b/ILSpy/ViewModels/NuGetPackageViewModel.cs new file mode 100644 index 000000000..e1ce5995d --- /dev/null +++ b/ILSpy/ViewModels/NuGetPackageViewModel.cs @@ -0,0 +1,73 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Media; + +using CommunityToolkit.Mvvm.ComponentModel; + +using ICSharpCode.ILSpy.NuGetFeeds; + +namespace ICSharpCode.ILSpy.ViewModels +{ + /// + /// One row in the "Open from NuGet feed" results list: the immutable feed + /// plus the observable . The icon starts as + /// the default NuGet logo and is swapped for the package's own icon once it has been fetched, + /// so the list can render immediately and fill in custom icons as they arrive. + /// + public sealed partial class NuGetPackageViewModel : ObservableObject + { + public NuGetPackageInfo Info { get; } + + [ObservableProperty] + IImage icon = Images.NuGet; + + public NuGetPackageViewModel(NuGetPackageInfo info) + { + Info = info; + } + + /// + /// Fetches the package's custom icon in the background and, on success, replaces the + /// default icon. A package without an icon URL keeps the default and never hits the loader. + /// + public async Task LoadIconAsync(INuGetIconLoader loader, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(Info.IconUrl)) + return; + try + { + var image = await loader.LoadIconAsync(Info.IconUrl, cancellationToken); + // The await resumes on the UI thread (the dialog view model drives this from + // there), so assigning the observable Icon raises PropertyChanged safely. + if (image != null && !cancellationToken.IsCancellationRequested) + Icon = image; + } + catch (Exception) + { + // This runs fire-and-forget, so nothing observes the returned task: any failure + // (cancellation from a superseded search, or a misbehaving loader) must be swallowed + // here rather than surface as an unobserved task exception. Keep the default icon. + } + } + } +} diff --git a/ILSpy/ViewModels/OpenFromNuGetFeedDialogViewModel.cs b/ILSpy/ViewModels/OpenFromNuGetFeedDialogViewModel.cs index 810c9d248..fd498a483 100644 --- a/ILSpy/ViewModels/OpenFromNuGetFeedDialogViewModel.cs +++ b/ILSpy/ViewModels/OpenFromNuGetFeedDialogViewModel.cs @@ -43,6 +43,7 @@ namespace ICSharpCode.ILSpy.ViewModels public const int MaxVersions = 100; readonly INuGetFeedClient client; + readonly INuGetIconLoader? iconLoader; readonly NuGetFeedSettings settings; readonly TimeSpan debounceDelay; @@ -56,6 +57,10 @@ namespace ICSharpCode.ILSpy.ViewModels CancellationTokenSource? downloadCts; + // Cancels the background icon downloads for a result set once it is replaced by a new + // search, so stale icons never land on rows that have been recycled to other packages. + CancellationTokenSource? iconLoadCts; + [ObservableProperty] string searchText = string.Empty; @@ -66,7 +71,7 @@ namespace ICSharpCode.ILSpy.ViewModels string selectedFeedUrl; [ObservableProperty] - NuGetPackageInfo? selectedPackage; + NuGetPackageViewModel? selectedPackage; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(OpenPackageCommand))] @@ -89,7 +94,7 @@ namespace ICSharpCode.ILSpy.ViewModels string? downloadedPackagePath; public ObservableCollection FeedUrls { get; } - public ObservableCollection Packages { get; } = new(); + public ObservableCollection Packages { get; } = new(); public ObservableCollection Versions { get; } = new(); /// @@ -99,9 +104,11 @@ namespace ICSharpCode.ILSpy.ViewModels public event Action? CloseRequested; public OpenFromNuGetFeedDialogViewModel( - INuGetFeedClient client, NuGetFeedSettings settings, TimeSpan? debounceDelay = null) + INuGetFeedClient client, NuGetFeedSettings settings, TimeSpan? debounceDelay = null, + INuGetIconLoader? iconLoader = null) { this.client = client; + this.iconLoader = iconLoader; this.settings = settings; this.debounceDelay = debounceDelay ?? TimeSpan.FromMilliseconds(300); FeedUrls = new ObservableCollection(settings.Feeds); @@ -119,7 +126,7 @@ namespace ICSharpCode.ILSpy.ViewModels partial void OnSelectedFeedUrlChanged(string value) => StartSearch(debounce: true); - partial void OnSelectedPackageChanged(NuGetPackageInfo? value) + partial void OnSelectedPackageChanged(NuGetPackageViewModel? value) => _ = LoadVersionsAsync(value); partial void OnSelectedVersionChanged(string? value) @@ -162,7 +169,7 @@ namespace ICSharpCode.ILSpy.ViewModels try { string path = await client.DownloadToGlobalPackagesFolderAsync( - SelectedFeedUrl?.Trim() ?? string.Empty, package.Id, version, cts.Token); + SelectedFeedUrl?.Trim() ?? string.Empty, package.Info.Id, version, cts.Token); ErrorMessage = null; DownloadedPackagePath = path; CloseRequested?.Invoke(path); @@ -190,9 +197,10 @@ namespace ICSharpCode.ILSpy.ViewModels searchCts?.Cancel(); versionsCts?.Cancel(); downloadCts?.Cancel(); + iconLoadCts?.Cancel(); } - async Task LoadVersionsAsync(NuGetPackageInfo? package) + async Task LoadVersionsAsync(NuGetPackageViewModel? package) { versionsCts?.Cancel(); var cts = versionsCts = new CancellationTokenSource(); @@ -204,7 +212,7 @@ namespace ICSharpCode.ILSpy.ViewModels try { var versions = await client.GetVersionsAsync( - SelectedFeedUrl?.Trim() ?? string.Empty, package.Id, IncludePrerelease, + SelectedFeedUrl?.Trim() ?? string.Empty, package.Info.Id, IncludePrerelease, MaxVersions, cts.Token); if (generation != versionsGeneration) return; @@ -247,11 +255,21 @@ namespace ICSharpCode.ILSpy.ViewModels ErrorMessage = null; if (!append) { + // A fresh result set replaces the list: stop the previous set's icon + // downloads so they can't complete onto recycled rows. + iconLoadCts?.Cancel(); + iconLoadCts = new CancellationTokenSource(); Packages.Clear(); SelectedPackage = null; } + var iconToken = (iconLoadCts ??= new CancellationTokenSource()).Token; foreach (var package in results) - Packages.Add(package); + { + var item = new NuGetPackageViewModel(package); + Packages.Add(item); + if (iconLoader != null) + _ = item.LoadIconAsync(iconLoader, iconToken); + } CanLoadMore = results.Count == PageSize; // Only a feed that actually answered earns a spot in the persisted MRU. diff --git a/ILSpy/Views/OpenFromNuGetFeedDialog.axaml b/ILSpy/Views/OpenFromNuGetFeedDialog.axaml index 0d875cf99..78d86cffa 100644 --- a/ILSpy/Views/OpenFromNuGetFeedDialog.axaml +++ b/ILSpy/Views/OpenFromNuGetFeedDialog.axaml @@ -1,7 +1,6 @@ + SelectedItem="{Binding SelectedPackage, Mode=TwoWay}" + ScrollViewer.HorizontalScrollBarVisibility="Disabled"> - + + Source="{Binding Icon}" /> - + + IsVisible="{Binding Info.Authors, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" + Text="{Binding Info.Authors, StringFormat='by {0}'}" /> + Text="{Binding Info.Description}" /> + Text="{Binding Info.LatestVersion, StringFormat='v{0}'}" /> @@ -79,9 +79,9 @@ IsVisible="{Binding SelectedPackage, Converter={x:Static ObjectConverters.IsNotNull}}"> - + + Text="{Binding SelectedPackage.Info.Id}" /> @@ -104,37 +104,37 @@ - + + Text="{Binding SelectedPackage.Info.Authors}" /> + Text="{Binding SelectedPackage.Info.LicenseUrl}" /> + Text="{Binding SelectedPackage.Info.Published, StringFormat='{}{0:D}'}" /> + Text="{Binding SelectedPackage.Info.ProjectUrl}" /> + Text="{Binding SelectedPackage.Info.DownloadCount, StringFormat='{}{0:N0}'}" /> + Text="{Binding SelectedPackage.Info.Tags}" /> diff --git a/ILSpy/Views/OpenFromNuGetFeedDialog.axaml.cs b/ILSpy/Views/OpenFromNuGetFeedDialog.axaml.cs index 7b276ff20..140a02edb 100644 --- a/ILSpy/Views/OpenFromNuGetFeedDialog.axaml.cs +++ b/ILSpy/Views/OpenFromNuGetFeedDialog.axaml.cs @@ -48,19 +48,20 @@ namespace ICSharpCode.ILSpy.Views // Runtime-loader/designer constructor; production callers and tests use the // overload below to inject the settings service and (in tests) a fake client. public OpenFromNuGetFeedDialog() - : this(settingsService: null, client: new NuGetFeedClient()) + : this(settingsService: null, client: new NuGetFeedClient(), iconLoader: new NuGetIconLoader()) { } public OpenFromNuGetFeedDialog( SettingsService? settingsService, INuGetFeedClient client, - TimeSpan? debounceDelay = null) + TimeSpan? debounceDelay = null, + INuGetIconLoader? iconLoader = null) { InitializeComponent(); var settings = settingsService?.GetSettings() ?? new NuGetFeedSettings(); - viewModel = new OpenFromNuGetFeedDialogViewModel(client, settings, debounceDelay); + viewModel = new OpenFromNuGetFeedDialogViewModel(client, settings, debounceDelay, iconLoader); DataContext = viewModel; Title = Loc.NuGetFeedSelectPackage;