Browse Source

Lazy-load custom package icons in the NuGet feed dialog

The "Open from NuGet feed" chooser rendered every result with the default
NuGet logo, even though the feed search already returns each package's own
IconUrl - it was fetched into NuGetPackageInfo and then ignored. The list
looked like a wall of identical icons, unlike the NuGet gallery.

NuGetPackageInfo is an immutable feed DTO bound straight into the ListBox, so
it can't raise PropertyChanged for an icon that arrives later. Rather than
bolt mutability and an Avalonia image type onto that DTO, introduce a thin
per-row view model (NuGetPackageViewModel) that owns an observable Icon
defaulting to the logo and swaps in the real icon once downloaded, plus an
INuGetIconLoader service that fetches and decodes off the UI thread. Icons
are decoded to 64px (rendered at 32) and cached by URL as the in-flight Task
so each URL downloads once and concurrent requests share it; the loader is
held for the command's lifetime so the cache survives reopening the dialog.
A per-search CancellationTokenSource stops a stale result set's downloads
from painting onto rows recycled by the next search. Any failure - non-http
URL, network error, decode error - collapses to null so a dead icon URL just
keeps the default.

Also disable horizontal scrolling on the results list. With it enabled
(the default), rows measured at unbounded width, so the star column stopped
filling the viewport and selecting a row brought the overflow into view -
the list jumped sideways and grew a scrollbar. The row layout is meant to
fit the width (the description wraps/trims), so horizontal scrolling was
never wanted.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3813/head
Christoph Wille 2 weeks ago
parent
commit
f302b1fb3e
  1. 4
      ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs
  2. 83
      ILSpy.Tests/NuGetFeeds/FakeNuGetIconLoader.cs
  3. 112
      ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs
  4. 15
      ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs
  5. 4
      ILSpy/Commands/FileCommands.cs
  6. 40
      ILSpy/NuGetFeeds/INuGetIconLoader.cs
  7. 98
      ILSpy/NuGetFeeds/NuGetIconLoader.cs
  8. 73
      ILSpy/ViewModels/NuGetPackageViewModel.cs
  9. 34
      ILSpy/ViewModels/OpenFromNuGetFeedDialogViewModel.cs
  10. 36
      ILSpy/Views/OpenFromNuGetFeedDialog.axaml
  11. 7
      ILSpy/Views/OpenFromNuGetFeedDialog.axaml.cs

4
ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs

@ -62,12 +62,12 @@ public sealed class FakeNuGetFeedClient : INuGetFeedClient
/// </summary> /// </summary>
public TaskCompletionSource<string>? PendingDownload { get; set; } public TaskCompletionSource<string>? 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, Id: id,
LatestVersion: version, LatestVersion: version,
Description: $"Description of {id}", Description: $"Description of {id}",
Authors: "Test Author", Authors: "Test Author",
IconUrl: null, IconUrl: iconUrl,
LicenseUrl: "https://licenses.example/MIT", LicenseUrl: "https://licenses.example/MIT",
ProjectUrl: $"https://github.example/{id}", ProjectUrl: $"https://github.example/{id}",
Tags: "test fake", Tags: "test fake",

83
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;
/// <summary>
/// In-memory <see cref="INuGetIconLoader"/>: 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.
/// </summary>
public sealed class FakeNuGetIconLoader : INuGetIconLoader
{
public ConcurrentBag<string> RequestedUrls { get; } = new();
/// <summary>When false, every load resolves to null (models a broken/missing icon).</summary>
public bool ReturnImage { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public TaskCompletionSource? Gate { get; set; }
public Task<IImage?> LoadIconAsync(string iconUrl, CancellationToken cancellationToken)
{
RequestedUrls.Add(iconUrl);
if (cancellationToken.IsCancellationRequested)
return Task.FromResult<IImage?>(null);
if (Gate != null)
return AwaitGateAsync(iconUrl, cancellationToken);
return Task.FromResult(Result(iconUrl));
}
async Task<IImage?> 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;
/// <summary>A trivial non-drawing <see cref="IImage"/> that carries the URL it stands in for.</summary>
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) { }
}
}

112
ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs

@ -25,6 +25,7 @@ using Avalonia.Headless.NUnit;
using AwesomeAssertions; using AwesomeAssertions;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.NuGetFeeds; using ICSharpCode.ILSpy.NuGetFeeds;
using ICSharpCode.ILSpy.ViewModels; 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"; const string CustomFeed = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json";
static (OpenFromNuGetFeedDialogViewModel vm, FakeNuGetFeedClient client, NuGetFeedSettings settings) 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(); var client = new FakeNuGetFeedClient();
settings ??= new NuGetFeedSettings(); 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); 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] [AvaloniaTest]
public async Task Refresh_Searches_The_Persisted_Feed_With_Empty_Term_And_Persisted_Prerelease() 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, client.SearchCalls[1].Skip.Should().Be(OpenFromNuGetFeedDialogViewModel.PageSize,
"the second page must start where the first ended"); "the second page must start where the first ended");
vm.CanLoadMore.Should().BeFalse("a short page means the listing is exhausted"); 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] [AvaloniaTest]
@ -169,7 +177,7 @@ public class OpenFromNuGetFeedViewModelTests
var (vm, client, _) = CreateViewModel(); var (vm, client, _) = CreateViewModel();
client.VersionsToReturn = new[] { "13.0.3", "13.0.2", "12.0.1" }; 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); await Waiters.WaitForAsync(() => vm.Versions.Count == 3);
vm.Versions.Should().Equal(new[] { "13.0.3", "13.0.2", "12.0.1" }, vm.Versions.Should().Equal(new[] { "13.0.3", "13.0.2", "12.0.1" },
@ -188,7 +196,7 @@ public class OpenFromNuGetFeedViewModelTests
vm.IncludePrerelease = true; vm.IncludePrerelease = true;
await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0); 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); await Waiters.WaitForAsync(() => client.VersionsCalls.Count > 0);
client.VersionsCalls.Last().IncludePrerelease.Should().BeTrue(); client.VersionsCalls.Last().IncludePrerelease.Should().BeTrue();
@ -198,11 +206,11 @@ public class OpenFromNuGetFeedViewModelTests
public async Task Switching_Packages_Replaces_The_Version_List() public async Task Switching_Packages_Replaces_The_Version_List()
{ {
var (vm, client, _) = CreateViewModel(); var (vm, client, _) = CreateViewModel();
vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("PackageA"); vm.SelectedPackage = Pkg("PackageA");
await Waiters.WaitForAsync(() => vm.Versions.Count > 0); await Waiters.WaitForAsync(() => vm.Versions.Count > 0);
client.VersionsToReturn = new[] { "2.0.0" }; client.VersionsToReturn = new[] { "2.0.0" };
vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("PackageB"); vm.SelectedPackage = Pkg("PackageB");
await Waiters.WaitForAsync(() => vm.Versions.Count == 1); await Waiters.WaitForAsync(() => vm.Versions.Count == 1);
vm.Versions.Should().Equal(new[] { "2.0.0" }); vm.Versions.Should().Equal(new[] { "2.0.0" });
@ -215,7 +223,7 @@ public class OpenFromNuGetFeedViewModelTests
{ {
var (vm, client, _) = CreateViewModel(); var (vm, client, _) = CreateViewModel();
client.VersionsToReturn = new[] { "13.0.3", "13.0.2" }; 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); await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
// A ComboBox resets its SelectedItem to null when its items change under it; // A ComboBox resets its SelectedItem to null when its items change under it;
@ -232,7 +240,7 @@ public class OpenFromNuGetFeedViewModelTests
var (vm, client, _) = CreateViewModel(); var (vm, client, _) = CreateViewModel();
client.VersionsException = new HttpRequestException("flat container unavailable"); client.VersionsException = new HttpRequestException("flat container unavailable");
vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); vm.SelectedPackage = Pkg("Newtonsoft.Json");
await Waiters.WaitForAsync(() => vm.ErrorMessage != null); await Waiters.WaitForAsync(() => vm.ErrorMessage != null);
vm.ErrorMessage.Should().Contain("flat container unavailable"); vm.ErrorMessage.Should().Contain("flat container unavailable");
@ -247,7 +255,7 @@ public class OpenFromNuGetFeedViewModelTests
vm.OpenPackageCommand.CanExecute(null).Should().BeFalse( vm.OpenPackageCommand.CanExecute(null).Should().BeFalse(
"there is nothing to download before a package/version is selected"); "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); await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
vm.OpenPackageCommand.CanExecute(null).Should().BeTrue(); vm.OpenPackageCommand.CanExecute(null).Should().BeTrue();
@ -260,7 +268,7 @@ public class OpenFromNuGetFeedViewModelTests
string? closedWith = null; string? closedWith = null;
vm.CloseRequested += path => closedWith = path; vm.CloseRequested += path => closedWith = path;
vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); vm.SelectedPackage = Pkg("Newtonsoft.Json");
await Waiters.WaitForAsync(() => vm.SelectedVersion != null); await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
vm.SelectedVersion = "13.0.2"; vm.SelectedVersion = "13.0.2";
@ -284,7 +292,7 @@ public class OpenFromNuGetFeedViewModelTests
bool closeRaised = false; bool closeRaised = false;
vm.CloseRequested += _ => closeRaised = true; vm.CloseRequested += _ => closeRaised = true;
vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); vm.SelectedPackage = Pkg("Newtonsoft.Json");
await Waiters.WaitForAsync(() => vm.SelectedVersion != null); await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
vm.OpenPackageCommand.Execute(null); vm.OpenPackageCommand.Execute(null);
await Waiters.WaitForAsync(() => vm.IsDownloading); await Waiters.WaitForAsync(() => vm.IsDownloading);
@ -308,7 +316,7 @@ public class OpenFromNuGetFeedViewModelTests
bool closeRaised = false; bool closeRaised = false;
vm.CloseRequested += _ => closeRaised = true; vm.CloseRequested += _ => closeRaised = true;
vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); vm.SelectedPackage = Pkg("Newtonsoft.Json");
await Waiters.WaitForAsync(() => vm.SelectedVersion != null); await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
vm.OpenPackageCommand.Execute(null); vm.OpenPackageCommand.Execute(null);
await Waiters.WaitForAsync(() => vm.ErrorMessage != null); await Waiters.WaitForAsync(() => vm.ErrorMessage != null);
@ -338,4 +346,82 @@ public class OpenFromNuGetFeedViewModelTests
await Waiters.WaitForAsync(() => vm.Packages.Count > 0); await Waiters.WaitForAsync(() => vm.Packages.Count > 0);
vm.ErrorMessage.Should().BeNull("a successful retry must dismiss the stale error"); 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");
}
} }

15
ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs

@ -21,6 +21,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.NUnit; using Avalonia.Headless.NUnit;
using Avalonia.VisualTree; using Avalonia.VisualTree;
@ -82,6 +83,20 @@ public class OpenFromNuGetFeedDialogStructureTests
errorBar!.IsVisible.Should().BeFalse("no error is showing before anything went wrong"); 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<ListBox>("PackagesList")!;
ScrollViewer.GetHorizontalScrollBarVisibility(list)
.Should().Be(ScrollBarVisibility.Disabled,
"selecting a package must not scroll or shift the results list sideways");
}
[AvaloniaTest] [AvaloniaTest]
public async Task Feed_ComboBox_Is_Editable_So_Custom_Feed_Urls_Can_Be_Typed() public async Task Feed_ComboBox_Is_Editable_So_Custom_Feed_Urls_Can_Be_Typed()
{ {

4
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 // One client for the command's lifetime so the per-feed service-index discovery
// and the NuGet HTTP cache survive across dialog invocations. // and the NuGet HTTP cache survive across dialog invocations.
readonly NuGetFeedClient feedClient = new(); 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) public override void Execute(object? parameter)
{ {
@ -142,7 +144,7 @@ namespace ICSharpCode.ILSpy.Commands
async Task ShowAsync(global::Avalonia.Controls.Window owner) 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 // The dialog result is the absolute path of the .nupkg in the global packages
// folder; ILSpy opens .nupkg natively (ArchiveFileLoader/LoadedPackage). // folder; ILSpy opens .nupkg natively (ArchiveFileLoader/LoadedPackage).
var path = await dlg.ShowDialog<string?>(owner); var path = await dlg.ShowDialog<string?>(owner);

40
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
{
/// <summary>
/// Fetches a package's custom icon for the "Open from NuGet feed" dialog. The production
/// implementation (<see cref="NuGetIconLoader"/>) downloads and decodes the image; tests
/// substitute a fake so no network is involved.
/// </summary>
public interface INuGetIconLoader
{
/// <summary>
/// Downloads and decodes the icon at <paramref name="iconUrl"/>. Returns
/// <see langword="null"/> 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.
/// </summary>
Task<IImage?> LoadIconAsync(string iconUrl, CancellationToken cancellationToken);
}
}

98
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
{
/// <summary>
/// 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.
/// </summary>
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<string, Task<IImage?>> cache =
new(StringComparer.OrdinalIgnoreCase);
public async Task<IImage?> 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<IImage?> 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;
}
}
}
}

73
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
{
/// <summary>
/// One row in the "Open from NuGet feed" results list: the immutable feed
/// <see cref="NuGetPackageInfo"/> plus the observable <see cref="Icon"/>. 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.
/// </summary>
public sealed partial class NuGetPackageViewModel : ObservableObject
{
public NuGetPackageInfo Info { get; }
[ObservableProperty]
IImage icon = Images.NuGet;
public NuGetPackageViewModel(NuGetPackageInfo info)
{
Info = info;
}
/// <summary>
/// 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.
/// </summary>
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.
}
}
}
}

34
ILSpy/ViewModels/OpenFromNuGetFeedDialogViewModel.cs

@ -43,6 +43,7 @@ namespace ICSharpCode.ILSpy.ViewModels
public const int MaxVersions = 100; public const int MaxVersions = 100;
readonly INuGetFeedClient client; readonly INuGetFeedClient client;
readonly INuGetIconLoader? iconLoader;
readonly NuGetFeedSettings settings; readonly NuGetFeedSettings settings;
readonly TimeSpan debounceDelay; readonly TimeSpan debounceDelay;
@ -56,6 +57,10 @@ namespace ICSharpCode.ILSpy.ViewModels
CancellationTokenSource? downloadCts; 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] [ObservableProperty]
string searchText = string.Empty; string searchText = string.Empty;
@ -66,7 +71,7 @@ namespace ICSharpCode.ILSpy.ViewModels
string selectedFeedUrl; string selectedFeedUrl;
[ObservableProperty] [ObservableProperty]
NuGetPackageInfo? selectedPackage; NuGetPackageViewModel? selectedPackage;
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(OpenPackageCommand))] [NotifyCanExecuteChangedFor(nameof(OpenPackageCommand))]
@ -89,7 +94,7 @@ namespace ICSharpCode.ILSpy.ViewModels
string? downloadedPackagePath; string? downloadedPackagePath;
public ObservableCollection<string> FeedUrls { get; } public ObservableCollection<string> FeedUrls { get; }
public ObservableCollection<NuGetPackageInfo> Packages { get; } = new(); public ObservableCollection<NuGetPackageViewModel> Packages { get; } = new();
public ObservableCollection<string> Versions { get; } = new(); public ObservableCollection<string> Versions { get; } = new();
/// <summary> /// <summary>
@ -99,9 +104,11 @@ namespace ICSharpCode.ILSpy.ViewModels
public event Action<string?>? CloseRequested; public event Action<string?>? CloseRequested;
public OpenFromNuGetFeedDialogViewModel( public OpenFromNuGetFeedDialogViewModel(
INuGetFeedClient client, NuGetFeedSettings settings, TimeSpan? debounceDelay = null) INuGetFeedClient client, NuGetFeedSettings settings, TimeSpan? debounceDelay = null,
INuGetIconLoader? iconLoader = null)
{ {
this.client = client; this.client = client;
this.iconLoader = iconLoader;
this.settings = settings; this.settings = settings;
this.debounceDelay = debounceDelay ?? TimeSpan.FromMilliseconds(300); this.debounceDelay = debounceDelay ?? TimeSpan.FromMilliseconds(300);
FeedUrls = new ObservableCollection<string>(settings.Feeds); FeedUrls = new ObservableCollection<string>(settings.Feeds);
@ -119,7 +126,7 @@ namespace ICSharpCode.ILSpy.ViewModels
partial void OnSelectedFeedUrlChanged(string value) => StartSearch(debounce: true); partial void OnSelectedFeedUrlChanged(string value) => StartSearch(debounce: true);
partial void OnSelectedPackageChanged(NuGetPackageInfo? value) partial void OnSelectedPackageChanged(NuGetPackageViewModel? value)
=> _ = LoadVersionsAsync(value); => _ = LoadVersionsAsync(value);
partial void OnSelectedVersionChanged(string? value) partial void OnSelectedVersionChanged(string? value)
@ -162,7 +169,7 @@ namespace ICSharpCode.ILSpy.ViewModels
try try
{ {
string path = await client.DownloadToGlobalPackagesFolderAsync( 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; ErrorMessage = null;
DownloadedPackagePath = path; DownloadedPackagePath = path;
CloseRequested?.Invoke(path); CloseRequested?.Invoke(path);
@ -190,9 +197,10 @@ namespace ICSharpCode.ILSpy.ViewModels
searchCts?.Cancel(); searchCts?.Cancel();
versionsCts?.Cancel(); versionsCts?.Cancel();
downloadCts?.Cancel(); downloadCts?.Cancel();
iconLoadCts?.Cancel();
} }
async Task LoadVersionsAsync(NuGetPackageInfo? package) async Task LoadVersionsAsync(NuGetPackageViewModel? package)
{ {
versionsCts?.Cancel(); versionsCts?.Cancel();
var cts = versionsCts = new CancellationTokenSource(); var cts = versionsCts = new CancellationTokenSource();
@ -204,7 +212,7 @@ namespace ICSharpCode.ILSpy.ViewModels
try try
{ {
var versions = await client.GetVersionsAsync( var versions = await client.GetVersionsAsync(
SelectedFeedUrl?.Trim() ?? string.Empty, package.Id, IncludePrerelease, SelectedFeedUrl?.Trim() ?? string.Empty, package.Info.Id, IncludePrerelease,
MaxVersions, cts.Token); MaxVersions, cts.Token);
if (generation != versionsGeneration) if (generation != versionsGeneration)
return; return;
@ -247,11 +255,21 @@ namespace ICSharpCode.ILSpy.ViewModels
ErrorMessage = null; ErrorMessage = null;
if (!append) 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(); Packages.Clear();
SelectedPackage = null; SelectedPackage = null;
} }
var iconToken = (iconLoadCts ??= new CancellationTokenSource()).Token;
foreach (var package in results) 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; CanLoadMore = results.Count == PageSize;
// Only a feed that actually answered earns a spot in the persisted MRU. // Only a feed that actually answered earns a spot in the persisted MRU.

36
ILSpy/Views/OpenFromNuGetFeedDialog.axaml

@ -1,7 +1,6 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:img="using:ICSharpCode.ILSpy" xmlns:img="using:ICSharpCode.ILSpy"
xmlns:nf="using:ICSharpCode.ILSpy.NuGetFeeds"
xmlns:vm="using:ICSharpCode.ILSpy.ViewModels" xmlns:vm="using:ICSharpCode.ILSpy.ViewModels"
x:Class="ICSharpCode.ILSpy.Views.OpenFromNuGetFeedDialog" x:Class="ICSharpCode.ILSpy.Views.OpenFromNuGetFeedDialog"
x:DataType="vm:OpenFromNuGetFeedDialogViewModel" x:DataType="vm:OpenFromNuGetFeedDialogViewModel"
@ -43,29 +42,30 @@
<Grid Grid.Column="0" RowDefinitions="*"> <Grid Grid.Column="0" RowDefinitions="*">
<ListBox Grid.Row="0" Name="PackagesList" <ListBox Grid.Row="0" Name="PackagesList"
ItemsSource="{Binding Packages}" ItemsSource="{Binding Packages}"
SelectedItem="{Binding SelectedPackage, Mode=TwoWay}"> SelectedItem="{Binding SelectedPackage, Mode=TwoWay}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="nf:NuGetPackageInfo"> <DataTemplate x:DataType="vm:NuGetPackageViewModel">
<!-- MinHeight reserves the two description lines (MaxLines below caps <!-- MinHeight reserves the two description lines (MaxLines below caps
them), so rows stay uniform whether a package has a long, short, them), so rows stay uniform whether a package has a long, short,
or missing description. --> or missing description. -->
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="8" Margin="2" <Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="8" Margin="2"
MinHeight="52"> MinHeight="52">
<Image Grid.Column="0" Width="32" Height="32" VerticalAlignment="Top" <Image Grid.Column="0" Width="32" Height="32" VerticalAlignment="Top"
Source="{x:Static img:Images.NuGet}" /> Source="{Binding Icon}" />
<StackPanel Grid.Column="1" Spacing="2"> <StackPanel Grid.Column="1" Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock FontWeight="Bold" Text="{Binding Id}" /> <TextBlock FontWeight="Bold" Text="{Binding Info.Id}" />
<TextBlock Opacity="0.75" <TextBlock Opacity="0.75"
IsVisible="{Binding Authors, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" IsVisible="{Binding Info.Authors, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Text="{Binding Authors, StringFormat='by {0}'}" /> Text="{Binding Info.Authors, StringFormat='by {0}'}" />
</StackPanel> </StackPanel>
<TextBlock Opacity="0.75" TextWrapping="Wrap" MaxLines="2" <TextBlock Opacity="0.75" TextWrapping="Wrap" MaxLines="2"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
Text="{Binding Description}" /> Text="{Binding Info.Description}" />
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" VerticalAlignment="Top" <TextBlock Grid.Column="2" VerticalAlignment="Top"
Text="{Binding LatestVersion, StringFormat='v{0}'}" /> Text="{Binding Info.LatestVersion, StringFormat='v{0}'}" />
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
@ -79,9 +79,9 @@
IsVisible="{Binding SelectedPackage, Converter={x:Static ObjectConverters.IsNotNull}}"> IsVisible="{Binding SelectedPackage, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Name="DetailPane" Spacing="8" Margin="0,0,4,0"> <StackPanel Name="DetailPane" Spacing="8" Margin="0,0,4,0">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Image Width="32" Height="32" Source="{x:Static img:Images.NuGet}" /> <Image Width="32" Height="32" Source="{Binding SelectedPackage.Icon}" />
<TextBlock FontSize="20" FontWeight="Bold" VerticalAlignment="Center" <TextBlock FontSize="20" FontWeight="Bold" VerticalAlignment="Center"
Text="{Binding SelectedPackage.Id}" /> Text="{Binding SelectedPackage.Info.Id}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
@ -104,37 +104,37 @@
</StackPanel> </StackPanel>
<TextBlock Name="DescriptionHeaderLabel" FontWeight="Bold" /> <TextBlock Name="DescriptionHeaderLabel" FontWeight="Bold" />
<TextBlock TextWrapping="Wrap" Text="{Binding SelectedPackage.Description}" /> <TextBlock TextWrapping="Wrap" Text="{Binding SelectedPackage.Info.Description}" />
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12" <Grid ColumnDefinitions="Auto,*" ColumnSpacing="12"
RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="4"> RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="4">
<TextBlock Grid.Row="0" Grid.Column="0" Name="AuthorsLabel" FontWeight="Bold" /> <TextBlock Grid.Row="0" Grid.Column="0" Name="AuthorsLabel" FontWeight="Bold" />
<TextBlock Grid.Row="0" Grid.Column="1" TextWrapping="Wrap" <TextBlock Grid.Row="0" Grid.Column="1" TextWrapping="Wrap"
Text="{Binding SelectedPackage.Authors}" /> Text="{Binding SelectedPackage.Info.Authors}" />
<TextBlock Grid.Row="1" Grid.Column="0" Name="LicenseLabel" FontWeight="Bold" /> <TextBlock Grid.Row="1" Grid.Column="0" Name="LicenseLabel" FontWeight="Bold" />
<TextBlock Grid.Row="1" Grid.Column="1" Name="LicenseLink" <TextBlock Grid.Row="1" Grid.Column="1" Name="LicenseLink"
TextDecorations="Underline" Foreground="#1B6EC2" Cursor="Hand" TextDecorations="Underline" Foreground="#1B6EC2" Cursor="Hand"
TextWrapping="Wrap" TextWrapping="Wrap"
Text="{Binding SelectedPackage.LicenseUrl}" /> Text="{Binding SelectedPackage.Info.LicenseUrl}" />
<TextBlock Grid.Row="2" Grid.Column="0" Name="PublishedLabel" FontWeight="Bold" /> <TextBlock Grid.Row="2" Grid.Column="0" Name="PublishedLabel" FontWeight="Bold" />
<TextBlock Grid.Row="2" Grid.Column="1" <TextBlock Grid.Row="2" Grid.Column="1"
Text="{Binding SelectedPackage.Published, StringFormat='{}{0:D}'}" /> Text="{Binding SelectedPackage.Info.Published, StringFormat='{}{0:D}'}" />
<TextBlock Grid.Row="3" Grid.Column="0" Name="ProjectUrlLabel" FontWeight="Bold" /> <TextBlock Grid.Row="3" Grid.Column="0" Name="ProjectUrlLabel" FontWeight="Bold" />
<TextBlock Grid.Row="3" Grid.Column="1" Name="ProjectUrlLink" <TextBlock Grid.Row="3" Grid.Column="1" Name="ProjectUrlLink"
TextDecorations="Underline" Foreground="#1B6EC2" Cursor="Hand" TextDecorations="Underline" Foreground="#1B6EC2" Cursor="Hand"
TextWrapping="Wrap" TextWrapping="Wrap"
Text="{Binding SelectedPackage.ProjectUrl}" /> Text="{Binding SelectedPackage.Info.ProjectUrl}" />
<TextBlock Grid.Row="4" Grid.Column="0" Name="DownloadsLabel" FontWeight="Bold" /> <TextBlock Grid.Row="4" Grid.Column="0" Name="DownloadsLabel" FontWeight="Bold" />
<TextBlock Grid.Row="4" Grid.Column="1" <TextBlock Grid.Row="4" Grid.Column="1"
Text="{Binding SelectedPackage.DownloadCount, StringFormat='{}{0:N0}'}" /> Text="{Binding SelectedPackage.Info.DownloadCount, StringFormat='{}{0:N0}'}" />
<TextBlock Grid.Row="5" Grid.Column="0" Name="TagsLabel" FontWeight="Bold" /> <TextBlock Grid.Row="5" Grid.Column="0" Name="TagsLabel" FontWeight="Bold" />
<TextBlock Grid.Row="5" Grid.Column="1" TextWrapping="Wrap" <TextBlock Grid.Row="5" Grid.Column="1" TextWrapping="Wrap"
Text="{Binding SelectedPackage.Tags}" /> Text="{Binding SelectedPackage.Info.Tags}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

7
ILSpy/Views/OpenFromNuGetFeedDialog.axaml.cs

@ -48,19 +48,20 @@ namespace ICSharpCode.ILSpy.Views
// Runtime-loader/designer constructor; production callers and tests use the // Runtime-loader/designer constructor; production callers and tests use the
// overload below to inject the settings service and (in tests) a fake client. // overload below to inject the settings service and (in tests) a fake client.
public OpenFromNuGetFeedDialog() public OpenFromNuGetFeedDialog()
: this(settingsService: null, client: new NuGetFeedClient()) : this(settingsService: null, client: new NuGetFeedClient(), iconLoader: new NuGetIconLoader())
{ {
} }
public OpenFromNuGetFeedDialog( public OpenFromNuGetFeedDialog(
SettingsService? settingsService, SettingsService? settingsService,
INuGetFeedClient client, INuGetFeedClient client,
TimeSpan? debounceDelay = null) TimeSpan? debounceDelay = null,
INuGetIconLoader? iconLoader = null)
{ {
InitializeComponent(); InitializeComponent();
var settings = settingsService?.GetSettings<NuGetFeedSettings>() ?? new NuGetFeedSettings(); var settings = settingsService?.GetSettings<NuGetFeedSettings>() ?? new NuGetFeedSettings();
viewModel = new OpenFromNuGetFeedDialogViewModel(client, settings, debounceDelay); viewModel = new OpenFromNuGetFeedDialogViewModel(client, settings, debounceDelay, iconLoader);
DataContext = viewModel; DataContext = viewModel;
Title = Loc.NuGetFeedSelectPackage; Title = Loc.NuGetFeedSelectPackage;

Loading…
Cancel
Save