diff --git a/ILSpy.ReadyToRun/packages.lock.json b/ILSpy.ReadyToRun/packages.lock.json
index dc8bc69e8..1eaad86c7 100644
--- a/ILSpy.ReadyToRun/packages.lock.json
+++ b/ILSpy.ReadyToRun/packages.lock.json
@@ -317,6 +317,49 @@
"System.Xml.XDocument": "4.3.0"
}
},
+ "Newtonsoft.Json": {
+ "type": "Transitive",
+ "resolved": "13.0.3",
+ "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
+ },
+ "NuGet.Common": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "uyXLqkbbZmkMvdHOR23l1EHW2hRmULtzoG3Ocj84VpGptnNfkODVboHGJfDfcn9Gi9oUNDs8/VjL4FZcST6zVg==",
+ "dependencies": {
+ "NuGet.Frameworks": "7.6.0"
+ }
+ },
+ "NuGet.Configuration": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "+bNj+YneC5CNg1vR+WZjLAakscJlsi0KhADZUgIJPn4pwh8/jeCUMC5ik5cpET/i+QOplDy7aJVTGkpdfrlPww==",
+ "dependencies": {
+ "NuGet.Common": "7.6.0",
+ "System.Security.Cryptography.ProtectedData": "8.0.0"
+ }
+ },
+ "NuGet.Frameworks": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
+ },
+ "NuGet.Packaging": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "TDp+qHzRBy1zjwiJGCbfpdO0jMG5hH/bk7p1EABLKv9p5SIykDPGnbuYXm2iZO0QJ/H+hOI/vo5LfqM17Q+G0w==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.3",
+ "NuGet.Configuration": "7.6.0",
+ "NuGet.Versioning": "7.6.0",
+ "System.Security.Cryptography.Pkcs": "8.0.1"
+ }
+ },
+ "NuGet.Versioning": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "TpZxfOoQBQk/0r/2uc1A1qNYIKHkJGgOrWP+ax3nsNAUN/1BOQMDrgmGADogSA4hOXH1ZJiyeYg4Ca+vUW0sEg=="
+ },
"Onigwrap": {
"type": "Transitive",
"resolved": "1.0.10",
@@ -1089,6 +1132,11 @@
"System.Threading.Tasks": "4.3.0"
}
},
+ "System.Security.Cryptography.ProtectedData": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg=="
+ },
"System.Security.Cryptography.X509Certificates": {
"type": "Transitive",
"resolved": "4.3.0",
@@ -1355,6 +1403,7 @@
"Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )",
"Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )",
+ "NuGet.Protocol": "[7.6.0, )",
"ProDataGrid": "[12.0.0, )",
"Svg.Controls.Skia.Avalonia": "[12.0.0.11, )",
"System.Composition.AttributedModel": "[10.0.9, )",
@@ -1524,6 +1573,15 @@
"resolved": "0.11.6",
"contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA=="
},
+ "NuGet.Protocol": {
+ "type": "CentralTransitive",
+ "requested": "[7.6.0, )",
+ "resolved": "7.6.0",
+ "contentHash": "Ccb9dJG9hW0FdHFjXoHmhJBBJRYSCeSJArLdZjyZj6/FEAIKezLn75KFQNrTPE5UiAp4HVrjBizufZ/0IXhfKQ==",
+ "dependencies": {
+ "NuGet.Packaging": "7.6.0"
+ }
+ },
"ProDataGrid": {
"type": "CentralTransitive",
"requested": "[12.0.0, )",
@@ -1623,6 +1681,12 @@
"runtime.native.System.Security.Cryptography.OpenSsl": "4.3.2"
}
},
+ "System.Security.Cryptography.Pkcs": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.9, )",
+ "resolved": "10.0.9",
+ "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A=="
+ },
"System.Text.RegularExpressions": {
"type": "CentralTransitive",
"requested": "[4.3.1, )",
@@ -2567,6 +2631,12 @@
"Microsoft.NETCore.Targets": "1.1.3",
"runtime.unix.System.Private.Uri": "4.3.0"
}
+ },
+ "System.Security.Cryptography.Pkcs": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.9, )",
+ "resolved": "10.0.9",
+ "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A=="
}
},
"net10.0/osx-arm64": {
@@ -3487,6 +3557,12 @@
"Microsoft.NETCore.Targets": "1.1.3",
"runtime.unix.System.Private.Uri": "4.3.0"
}
+ },
+ "System.Security.Cryptography.Pkcs": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.9, )",
+ "resolved": "10.0.9",
+ "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A=="
}
},
"net10.0/win-arm64": {
@@ -4390,6 +4466,12 @@
"Microsoft.NETCore.Platforms": "1.1.1",
"Microsoft.NETCore.Targets": "1.1.3"
}
+ },
+ "System.Security.Cryptography.Pkcs": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.9, )",
+ "resolved": "10.0.9",
+ "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A=="
}
},
"net10.0/win-x64": {
@@ -5293,6 +5375,12 @@
"Microsoft.NETCore.Platforms": "1.1.1",
"Microsoft.NETCore.Targets": "1.1.3"
}
+ },
+ "System.Security.Cryptography.Pkcs": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.9, )",
+ "resolved": "10.0.9",
+ "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A=="
}
}
}
diff --git a/ILSpy.Tests.Windows/packages.lock.json b/ILSpy.Tests.Windows/packages.lock.json
index 0b8ee64fb..4a409c983 100644
--- a/ILSpy.Tests.Windows/packages.lock.json
+++ b/ILSpy.Tests.Windows/packages.lock.json
@@ -539,6 +539,49 @@
"System.Xml.XDocument": "4.3.0"
}
},
+ "Newtonsoft.Json": {
+ "type": "Transitive",
+ "resolved": "13.0.3",
+ "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
+ },
+ "NuGet.Common": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "uyXLqkbbZmkMvdHOR23l1EHW2hRmULtzoG3Ocj84VpGptnNfkODVboHGJfDfcn9Gi9oUNDs8/VjL4FZcST6zVg==",
+ "dependencies": {
+ "NuGet.Frameworks": "7.6.0"
+ }
+ },
+ "NuGet.Configuration": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "+bNj+YneC5CNg1vR+WZjLAakscJlsi0KhADZUgIJPn4pwh8/jeCUMC5ik5cpET/i+QOplDy7aJVTGkpdfrlPww==",
+ "dependencies": {
+ "NuGet.Common": "7.6.0",
+ "System.Security.Cryptography.ProtectedData": "8.0.0"
+ }
+ },
+ "NuGet.Frameworks": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
+ },
+ "NuGet.Packaging": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "TDp+qHzRBy1zjwiJGCbfpdO0jMG5hH/bk7p1EABLKv9p5SIykDPGnbuYXm2iZO0QJ/H+hOI/vo5LfqM17Q+G0w==",
+ "dependencies": {
+ "Newtonsoft.Json": "13.0.3",
+ "NuGet.Configuration": "7.6.0",
+ "NuGet.Versioning": "7.6.0",
+ "System.Security.Cryptography.Pkcs": "8.0.1"
+ }
+ },
+ "NuGet.Versioning": {
+ "type": "Transitive",
+ "resolved": "7.6.0",
+ "contentHash": "TpZxfOoQBQk/0r/2uc1A1qNYIKHkJGgOrWP+ax3nsNAUN/1BOQMDrgmGADogSA4hOXH1ZJiyeYg4Ca+vUW0sEg=="
+ },
"Onigwrap": {
"type": "Transitive",
"resolved": "1.0.10",
@@ -1309,6 +1352,11 @@
"System.Threading.Tasks": "4.3.0"
}
},
+ "System.Security.Cryptography.ProtectedData": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg=="
+ },
"System.Security.Cryptography.X509Certificates": {
"type": "Transitive",
"resolved": "4.3.0",
@@ -1575,6 +1623,7 @@
"Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )",
"Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )",
+ "NuGet.Protocol": "[7.6.0, )",
"ProDataGrid": "[12.0.0, )",
"Svg.Controls.Skia.Avalonia": "[12.0.0.11, )",
"System.Composition.AttributedModel": "[10.0.9, )",
@@ -1751,6 +1800,15 @@
"resolved": "0.11.6",
"contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA=="
},
+ "NuGet.Protocol": {
+ "type": "CentralTransitive",
+ "requested": "[7.6.0, )",
+ "resolved": "7.6.0",
+ "contentHash": "Ccb9dJG9hW0FdHFjXoHmhJBBJRYSCeSJArLdZjyZj6/FEAIKezLn75KFQNrTPE5UiAp4HVrjBizufZ/0IXhfKQ==",
+ "dependencies": {
+ "NuGet.Packaging": "7.6.0"
+ }
+ },
"ProDataGrid": {
"type": "CentralTransitive",
"requested": "[12.0.0, )",
@@ -1862,6 +1920,12 @@
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
+ "System.Security.Cryptography.Pkcs": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.9, )",
+ "resolved": "10.0.9",
+ "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A=="
+ },
"System.Text.RegularExpressions": {
"type": "CentralTransitive",
"requested": "[4.3.1, )",
diff --git a/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs b/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs
new file mode 100644
index 000000000..0035a17f5
--- /dev/null
+++ b/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs
@@ -0,0 +1,115 @@
+// 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.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using ILSpy.NuGetFeeds;
+
+namespace ICSharpCode.ILSpy.Tests.NuGetFeeds;
+
+///
+/// Scriptable in-memory : records every call, returns
+/// configurable results, throws configurable exceptions, and can hold a download open
+/// on a so cancellation paths become testable.
+///
+public sealed class FakeNuGetFeedClient : INuGetFeedClient
+{
+ public sealed record SearchCall(string FeedUrl, string SearchText, bool IncludePrerelease, int Skip, int Take);
+ public sealed record VersionsCall(string FeedUrl, string PackageId, bool IncludePrerelease, int MaxCount);
+ public sealed record DownloadCall(string FeedUrl, string PackageId, string Version);
+
+ public List SearchCalls { get; } = new();
+ public List VersionsCalls { get; } = new();
+ public List DownloadCalls { get; } = new();
+
+ /// Computes search results per call; defaults to one well-known package.
+ public Func> SearchHandler { get; set; }
+ = call => new[] { MakePackage("Newtonsoft.Json") };
+
+ public Exception? SearchException { get; set; }
+
+ public IReadOnlyList VersionsToReturn { get; set; } = new[] { "13.0.3", "13.0.2", "12.0.1" };
+
+ public Exception? VersionsException { get; set; }
+
+ public string DownloadResultPath { get; set; }
+ = @"C:\nuget-cache\newtonsoft.json\13.0.3\newtonsoft.json.13.0.3.nupkg";
+
+ public Exception? DownloadException { get; set; }
+
+ ///
+ /// When set, downloads do not complete until this source is resolved (or the call's
+ /// cancellation token fires), letting tests cancel a download mid-flight.
+ ///
+ public TaskCompletionSource? PendingDownload { get; set; }
+
+ public static NuGetPackageInfo MakePackage(string id, string version = "1.0.0") => new(
+ Id: id,
+ LatestVersion: version,
+ Description: $"Description of {id}",
+ Authors: "Test Author",
+ IconUrl: null,
+ LicenseUrl: "https://licenses.example/MIT",
+ ProjectUrl: $"https://github.example/{id}",
+ Tags: "test fake",
+ DownloadCount: 42,
+ Published: new DateTimeOffset(2024, 1, 2, 0, 0, 0, TimeSpan.Zero));
+
+ public static IReadOnlyList MakePage(int count, int offset = 0)
+ => Enumerable.Range(offset, count).Select(i => MakePackage($"Package{i:D3}")).ToArray();
+
+ public Task> SearchAsync(
+ string feedUrl, string searchText, bool includePrerelease,
+ int skip, int take, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var call = new SearchCall(feedUrl, searchText, includePrerelease, skip, take);
+ SearchCalls.Add(call);
+ if (SearchException != null)
+ return Task.FromException>(SearchException);
+ return Task.FromResult(SearchHandler(call));
+ }
+
+ public Task> GetVersionsAsync(
+ string feedUrl, string packageId, bool includePrerelease,
+ int maxCount, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ VersionsCalls.Add(new VersionsCall(feedUrl, packageId, includePrerelease, maxCount));
+ if (VersionsException != null)
+ return Task.FromException>(VersionsException);
+ return Task.FromResult(VersionsToReturn);
+ }
+
+ public async Task DownloadToGlobalPackagesFolderAsync(
+ string feedUrl, string packageId, string version,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DownloadCalls.Add(new DownloadCall(feedUrl, packageId, version));
+ if (DownloadException != null)
+ throw DownloadException;
+ if (PendingDownload != null)
+ return await PendingDownload.Task.WaitAsync(cancellationToken);
+ return DownloadResultPath;
+ }
+}
diff --git a/ILSpy.Tests/NuGetFeeds/NuGetFeedSettingsTests.cs b/ILSpy.Tests/NuGetFeeds/NuGetFeedSettingsTests.cs
new file mode 100644
index 000000000..053b4ec3c
--- /dev/null
+++ b/ILSpy.Tests/NuGetFeeds/NuGetFeedSettingsTests.cs
@@ -0,0 +1,133 @@
+// 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.Linq;
+using System.Xml.Linq;
+
+using AwesomeAssertions;
+
+using ILSpy.NuGetFeeds;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.NuGetFeeds;
+
+///
+/// Pins the persistence contract of the "Open from NuGet feed" dialog's settings section:
+/// the nuget.org feed is always available even after loading damaged settings XML, the
+/// feed MRU stays bounded and free of case-duplicates, and the selected feed plus the
+/// prerelease toggle survive a save/load round-trip.
+///
+[TestFixture]
+public class NuGetFeedSettingsTests
+{
+ const string CustomFeed = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json";
+
+ [Test]
+ public void Defaults_Offer_The_NuGetOrg_Feed_Selected_Without_Prerelease()
+ {
+ var settings = new NuGetFeedSettings();
+
+ settings.Feeds.Should().Equal(new[] { NuGetFeedSettings.DefaultFeed },
+ "a fresh install must offer exactly the official nuget.org V3 feed");
+ settings.SelectedFeed.Should().Be(NuGetFeedSettings.DefaultFeed);
+ settings.IncludePrerelease.Should().BeFalse("stable-only is the conventional default");
+ }
+
+ [Test]
+ public void SaveToXml_LoadFromXml_RoundTrips_Feeds_Selection_And_Prerelease()
+ {
+ var settings = new NuGetFeedSettings();
+ settings.RememberFeed(CustomFeed);
+ settings.SelectedFeed = CustomFeed;
+ settings.IncludePrerelease = true;
+
+ var restored = new NuGetFeedSettings();
+ restored.LoadFromXml(settings.SaveToXml());
+
+ restored.Feeds.Should().Equal(settings.Feeds);
+ restored.SelectedFeed.Should().Be(CustomFeed);
+ restored.IncludePrerelease.Should().BeTrue();
+ }
+
+ [Test]
+ public void LoadFromXml_On_An_Empty_Section_Falls_Back_To_Defaults()
+ {
+ var settings = new NuGetFeedSettings();
+ settings.RememberFeed(CustomFeed);
+ settings.SelectedFeed = CustomFeed;
+ settings.IncludePrerelease = true;
+
+ settings.LoadFromXml(new XElement("NuGetFeedSettings"));
+
+ settings.Feeds.Should().Equal(new[] { NuGetFeedSettings.DefaultFeed });
+ settings.SelectedFeed.Should().Be(NuGetFeedSettings.DefaultFeed);
+ settings.IncludePrerelease.Should().BeFalse();
+ }
+
+ [Test]
+ public void LoadFromXml_Ignores_Garbage_Content_And_Keeps_The_Default_Feed_Present()
+ {
+ var settings = new NuGetFeedSettings();
+ var section = new XElement("NuGetFeedSettings",
+ new XElement("Feeds",
+ new XElement("Feed", " "),
+ new XElement("Feed", CustomFeed)),
+ new XElement("SelectedFeed", "https://feed.example/that/was/never/remembered"),
+ new XElement("IncludePrerelease", "not-a-bool"));
+
+ settings.LoadFromXml(section);
+
+ settings.Feeds.Should().Contain(NuGetFeedSettings.DefaultFeed,
+ "the nuget.org feed must survive any settings file content");
+ settings.Feeds.Should().Contain(CustomFeed);
+ settings.Feeds.Should().NotContain(f => string.IsNullOrWhiteSpace(f));
+ settings.Feeds.Should().Contain(settings.SelectedFeed,
+ "the selection must always point at an offered feed");
+ settings.IncludePrerelease.Should().BeFalse("malformed booleans fall back to the default");
+ }
+
+ [Test]
+ public void RememberFeed_Dedupes_Case_Insensitively_And_Ignores_Blank_Urls()
+ {
+ var settings = new NuGetFeedSettings();
+
+ settings.RememberFeed(CustomFeed);
+ settings.RememberFeed(CustomFeed.ToUpperInvariant());
+ settings.RememberFeed(" " + CustomFeed + " ");
+ settings.RememberFeed("");
+ settings.RememberFeed(" ");
+
+ settings.Feeds.Should().HaveCount(2, "the default feed plus one custom feed, no duplicates");
+ settings.Feeds.Count(f => string.Equals(f, CustomFeed, System.StringComparison.OrdinalIgnoreCase))
+ .Should().Be(1);
+ }
+
+ [Test]
+ public void RememberFeed_Caps_The_List_But_Never_Evicts_The_Default_Feed()
+ {
+ var settings = new NuGetFeedSettings();
+ for (int i = 0; i < 25; i++)
+ settings.RememberFeed($"https://feed{i}.example/v3/index.json");
+
+ settings.Feeds.Count.Should().BeLessThanOrEqualTo(NuGetFeedSettings.MaxFeeds);
+ settings.Feeds.Should().Contain(NuGetFeedSettings.DefaultFeed);
+ settings.Feeds.Should().Contain("https://feed24.example/v3/index.json",
+ "the most recently used feed must survive the cap");
+ }
+}
diff --git a/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs b/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs
new file mode 100644
index 000000000..3bbe87ab9
--- /dev/null
+++ b/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs
@@ -0,0 +1,341 @@
+// 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.Linq;
+using System.Net.Http;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ILSpy.NuGetFeeds;
+using ILSpy.ViewModels;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.NuGetFeeds;
+
+///
+/// Behavior of the "Open from NuGet feed" dialog's view model against a fake feed client:
+/// search runs against the persisted feed with the persisted prerelease flag, typing is
+/// debounced into a single query, paging appends via skip/take, and feed errors land in
+/// ErrorMessage instead of escaping. The fake records every call, so the assertions
+/// pin the exact requests the real NuGet client would receive.
+///
+[TestFixture]
+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)
+ {
+ var client = new FakeNuGetFeedClient();
+ settings ??= new NuGetFeedSettings();
+ var vm = new OpenFromNuGetFeedDialogViewModel(client, settings, debounceDelay ?? TimeSpan.Zero);
+ return (vm, client, settings);
+ }
+
+ [AvaloniaTest]
+ public async Task Refresh_Searches_The_Persisted_Feed_With_Empty_Term_And_Persisted_Prerelease()
+ {
+ var settings = new NuGetFeedSettings { IncludePrerelease = true };
+ settings.RememberFeed(CustomFeed);
+ settings.SelectedFeed = CustomFeed;
+ var (vm, client, _) = CreateViewModel(settings: settings);
+
+ vm.RefreshCommand.Execute(null);
+ await Waiters.WaitForAsync(() => vm.Packages.Count > 0);
+
+ client.SearchCalls.Should().ContainSingle()
+ .Which.Should().Be(new FakeNuGetFeedClient.SearchCall(
+ CustomFeed, "", true, 0, OpenFromNuGetFeedDialogViewModel.PageSize),
+ "the initial listing must use the persisted feed, prerelease flag, an empty term, and the first page");
+ vm.IsSearching.Should().BeFalse();
+ }
+
+ [AvaloniaTest]
+ public async Task Typing_Is_Debounced_Into_A_Single_Search_With_The_Final_Term()
+ {
+ var (vm, client, _) = CreateViewModel(debounceDelay: TimeSpan.FromMilliseconds(75));
+
+ vm.SearchText = "n";
+ vm.SearchText = "ne";
+ vm.SearchText = "newtonsoft";
+ await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0);
+ // Allow a full extra debounce window to elapse so stale timers would have fired.
+ await Task.Delay(150);
+
+ client.SearchCalls.Should().ContainSingle("intermediate keystrokes must be coalesced")
+ .Which.SearchText.Should().Be("newtonsoft");
+ }
+
+ [AvaloniaTest]
+ public async Task Toggling_Prerelease_Reruns_The_Search_And_Persists_The_Flag()
+ {
+ var (vm, client, settings) = CreateViewModel();
+
+ vm.IncludePrerelease = true;
+ await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0);
+
+ client.SearchCalls.Last().IncludePrerelease.Should().BeTrue();
+ settings.IncludePrerelease.Should().BeTrue("the toggle must survive an app restart");
+ }
+
+ [AvaloniaTest]
+ public async Task Switching_To_A_New_Feed_Searches_It_And_Remembers_It_After_Success()
+ {
+ var (vm, client, settings) = CreateViewModel();
+
+ vm.SelectedFeedUrl = CustomFeed;
+ await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0);
+ await Waiters.WaitForAsync(() => vm.FeedUrls.Contains(CustomFeed));
+
+ client.SearchCalls.Last().FeedUrl.Should().Be(CustomFeed);
+ settings.Feeds.Should().Contain(CustomFeed,
+ "a feed that produced results is worth offering again next time");
+ settings.SelectedFeed.Should().Be(CustomFeed);
+ }
+
+ [AvaloniaTest]
+ public async Task A_Failing_Feed_Does_Not_Get_Remembered()
+ {
+ var (vm, client, settings) = CreateViewModel();
+ client.SearchException = new HttpRequestException("503 Service Unavailable");
+
+ vm.SelectedFeedUrl = CustomFeed;
+ await Waiters.WaitForAsync(() => vm.ErrorMessage != null);
+
+ settings.Feeds.Should().NotContain(CustomFeed,
+ "feeds are only added to the MRU once they answered a search");
+ }
+
+ [AvaloniaTest]
+ public async Task A_Full_Page_Enables_LoadMore_Which_Appends_The_Next_Page()
+ {
+ var (vm, client, _) = CreateViewModel();
+ client.SearchHandler = call => FakeNuGetFeedClient.MakePage(
+ count: call.Skip == 0 ? OpenFromNuGetFeedDialogViewModel.PageSize : 3,
+ offset: call.Skip);
+
+ vm.RefreshCommand.Execute(null);
+ await Waiters.WaitForAsync(() => vm.Packages.Count == OpenFromNuGetFeedDialogViewModel.PageSize);
+ vm.CanLoadMore.Should().BeTrue("a full first page suggests more results exist");
+
+ vm.LoadMoreCommand.Execute(null);
+ await Waiters.WaitForAsync(() => vm.Packages.Count == OpenFromNuGetFeedDialogViewModel.PageSize + 3);
+
+ client.SearchCalls.Should().HaveCount(2);
+ 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();
+ }
+
+ [AvaloniaTest]
+ public async Task ClearSearch_Resets_The_Term_And_Lists_The_Feed_Defaults_Again()
+ {
+ var (vm, client, _) = CreateViewModel();
+ vm.SearchText = "newtonsoft";
+ await Waiters.WaitForAsync(() => client.SearchCalls.Count == 1);
+
+ vm.ClearSearchCommand.Execute(null);
+ await Waiters.WaitForAsync(() => client.SearchCalls.Count >= 2);
+
+ vm.SearchText.Should().BeEmpty();
+ client.SearchCalls.Last().SearchText.Should().BeEmpty();
+ }
+
+ [AvaloniaTest]
+ public async Task Selecting_A_Package_Loads_Its_Versions_And_Preselects_The_Newest()
+ {
+ var (vm, client, _) = CreateViewModel();
+ client.VersionsToReturn = new[] { "13.0.3", "13.0.2", "12.0.1" };
+
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json");
+ await Waiters.WaitForAsync(() => vm.Versions.Count == 3);
+
+ vm.Versions.Should().Equal(new[] { "13.0.3", "13.0.2", "12.0.1" },
+ "the dropdown lists versions in the order the client returns them (newest first)");
+ vm.SelectedVersion.Should().Be("13.0.3", "the newest version is the most likely pick");
+ client.VersionsCalls.Should().ContainSingle()
+ .Which.Should().Be(new FakeNuGetFeedClient.VersionsCall(
+ NuGetFeedSettings.DefaultFeed, "Newtonsoft.Json", false,
+ OpenFromNuGetFeedDialogViewModel.MaxVersions));
+ }
+
+ [AvaloniaTest]
+ public async Task Version_Load_Respects_The_Prerelease_Toggle()
+ {
+ var (vm, client, _) = CreateViewModel();
+ vm.IncludePrerelease = true;
+ await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0);
+
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json");
+ await Waiters.WaitForAsync(() => client.VersionsCalls.Count > 0);
+
+ client.VersionsCalls.Last().IncludePrerelease.Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task Switching_Packages_Replaces_The_Version_List()
+ {
+ var (vm, client, _) = CreateViewModel();
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("PackageA");
+ await Waiters.WaitForAsync(() => vm.Versions.Count > 0);
+
+ client.VersionsToReturn = new[] { "2.0.0" };
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("PackageB");
+ await Waiters.WaitForAsync(() => vm.Versions.Count == 1);
+
+ vm.Versions.Should().Equal(new[] { "2.0.0" });
+ vm.SelectedVersion.Should().Be("2.0.0");
+ client.VersionsCalls.Last().PackageId.Should().Be("PackageB");
+ }
+
+ [AvaloniaTest]
+ public async Task A_Cleared_Version_Selection_Snaps_Back_To_The_Latest_Version()
+ {
+ var (vm, client, _) = CreateViewModel();
+ client.VersionsToReturn = new[] { "13.0.3", "13.0.2" };
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json");
+ await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
+
+ // A ComboBox resets its SelectedItem to null when its items change under it;
+ // the view model must answer by reselecting the latest version, never sitting
+ // on an empty selection while versions are available.
+ vm.SelectedVersion = null;
+
+ vm.SelectedVersion.Should().Be("13.0.3");
+ }
+
+ [AvaloniaTest]
+ public async Task Version_Load_Failure_Surfaces_In_ErrorMessage()
+ {
+ var (vm, client, _) = CreateViewModel();
+ client.VersionsException = new HttpRequestException("flat container unavailable");
+
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json");
+ await Waiters.WaitForAsync(() => vm.ErrorMessage != null);
+
+ vm.ErrorMessage.Should().Contain("flat container unavailable");
+ vm.Versions.Should().BeEmpty();
+ vm.SelectedVersion.Should().BeNull();
+ }
+
+ [AvaloniaTest]
+ public async Task Open_Is_Disabled_Until_A_Version_Is_Available()
+ {
+ var (vm, client, _) = CreateViewModel();
+ vm.OpenPackageCommand.CanExecute(null).Should().BeFalse(
+ "there is nothing to download before a package/version is selected");
+
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json");
+ await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
+
+ vm.OpenPackageCommand.CanExecute(null).Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task Open_Downloads_The_Selected_Version_And_Requests_Close_With_The_Nupkg_Path()
+ {
+ var (vm, client, _) = CreateViewModel();
+ string? closedWith = null;
+ vm.CloseRequested += path => closedWith = path;
+
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json");
+ await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
+ vm.SelectedVersion = "13.0.2";
+
+ vm.OpenPackageCommand.Execute(null);
+ await Waiters.WaitForAsync(() => closedWith != null);
+
+ closedWith.Should().Be(client.DownloadResultPath,
+ "the dialog result is the cached .nupkg the command then opens");
+ vm.DownloadedPackagePath.Should().Be(client.DownloadResultPath);
+ client.DownloadCalls.Should().ContainSingle()
+ .Which.Should().Be(new FakeNuGetFeedClient.DownloadCall(
+ NuGetFeedSettings.DefaultFeed, "Newtonsoft.Json", "13.0.2"));
+ vm.IsDownloading.Should().BeFalse();
+ }
+
+ [AvaloniaTest]
+ public async Task Cancelling_A_Download_Leaves_The_Dialog_Open_Without_An_Error()
+ {
+ var (vm, client, _) = CreateViewModel();
+ client.PendingDownload = new TaskCompletionSource();
+ bool closeRaised = false;
+ vm.CloseRequested += _ => closeRaised = true;
+
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json");
+ await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
+ vm.OpenPackageCommand.Execute(null);
+ await Waiters.WaitForAsync(() => vm.IsDownloading);
+
+ vm.OpenPackageCommand.CanExecute(null).Should().BeFalse(
+ "a second download must not start while one is running");
+
+ vm.CancelDownloadCommand.Execute(null);
+ await Waiters.WaitForAsync(() => !vm.IsDownloading);
+
+ closeRaised.Should().BeFalse("a cancelled download must keep the dialog open");
+ vm.ErrorMessage.Should().BeNull("user-initiated cancellation is not an error");
+ vm.OpenPackageCommand.CanExecute(null).Should().BeTrue("the user can retry");
+ }
+
+ [AvaloniaTest]
+ public async Task A_Failed_Download_Surfaces_The_Error_And_Keeps_The_Dialog_Open()
+ {
+ var (vm, client, _) = CreateViewModel();
+ client.DownloadException = new HttpRequestException("connection reset by peer");
+ bool closeRaised = false;
+ vm.CloseRequested += _ => closeRaised = true;
+
+ vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json");
+ await Waiters.WaitForAsync(() => vm.SelectedVersion != null);
+ vm.OpenPackageCommand.Execute(null);
+ await Waiters.WaitForAsync(() => vm.ErrorMessage != null);
+
+ vm.ErrorMessage.Should().Contain("connection reset by peer");
+ closeRaised.Should().BeFalse();
+ vm.IsDownloading.Should().BeFalse();
+ vm.OpenPackageCommand.CanExecute(null).Should().BeTrue("the user can retry after a failure");
+ }
+
+ [AvaloniaTest]
+ public async Task Feed_Errors_Surface_In_ErrorMessage_And_Clear_On_The_Next_Success()
+ {
+ var (vm, client, _) = CreateViewModel();
+ client.SearchException = new HttpRequestException("name or service not known");
+
+ vm.RefreshCommand.Execute(null);
+ await Waiters.WaitForAsync(() => vm.ErrorMessage != null);
+
+ vm.ErrorMessage.Should().Contain("name or service not known",
+ "the user must see why the feed produced nothing");
+ vm.IsSearching.Should().BeFalse();
+ vm.Packages.Should().BeEmpty();
+
+ client.SearchException = null;
+ vm.RefreshCommand.Execute(null);
+ await Waiters.WaitForAsync(() => vm.Packages.Count > 0);
+ vm.ErrorMessage.Should().BeNull("a successful retry must dismiss the stale error");
+ }
+}
diff --git a/ILSpy.Tests/Views/MainMenuTests.cs b/ILSpy.Tests/Views/MainMenuTests.cs
index b044b7743..87884c7e4 100644
--- a/ILSpy.Tests/Views/MainMenuTests.cs
+++ b/ILSpy.Tests/Views/MainMenuTests.cs
@@ -51,6 +51,33 @@ public class MainMenuTests
return null;
}
+ [AvaloniaTest]
+ public void OpenFromNuGetFeed_item_sits_between_GAC_and_Reload_and_works_on_every_OS()
+ {
+ var window = new Window();
+ MainMenu.Attach(window);
+
+ var menu = NativeMenu.GetMenu(window);
+ menu.Should().NotBeNull();
+
+ var nuget = Find(menu!, i => i.Header?.Contains("NuGet feed", StringComparison.OrdinalIgnoreCase) == true);
+ nuget.Should().NotBeNull("the File menu must contain an 'Open from NuGet feed' item");
+ nuget!.IsEnabled.Should().BeTrue("NuGet feeds are reachable from every OS, unlike the GAC");
+
+ var fileSubmenu = menu!.Items.OfType()
+ .Select(i => i.Menu)
+ .First(m => m != null && m.Items.OfType()
+ .Any(i => i.Header?.Contains("GAC", StringComparison.OrdinalIgnoreCase) == true));
+ var items = fileSubmenu!.Items.OfType().ToList();
+ int gacIndex = items.FindIndex(i => i.Header?.Contains("GAC", StringComparison.OrdinalIgnoreCase) == true);
+ int nugetIndex = items.FindIndex(i => i.Header?.Contains("NuGet feed", StringComparison.OrdinalIgnoreCase) == true);
+ int reloadIndex = items.FindIndex(i => i.Header?.Contains("Reload", StringComparison.OrdinalIgnoreCase) == true);
+
+ nugetIndex.Should().BeGreaterThan(gacIndex,
+ "the WPF File menu groups the open-from sources right below Open, GAC first");
+ nugetIndex.Should().BeLessThan(reloadIndex, "Reload ends the open group");
+ }
+
[AvaloniaTest]
public void OpenFromGac_item_enabled_state_follows_the_command()
{
diff --git a/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs b/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs
new file mode 100644
index 000000000..9400e69c7
--- /dev/null
+++ b/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs
@@ -0,0 +1,167 @@
+// 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.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Controls;
+using Avalonia.Headless.NUnit;
+using Avalonia.VisualTree;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.Tests.NuGetFeeds;
+
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Views;
+
+///
+/// Pins the package-chooser dialog's shape:
+/// search box, pre-release toggle, refresh, an editable package-source ComboBox,
+/// a results list, a version dropdown with an Open button, a Cancel button, and an
+/// error bar that appears when the feed reports a problem. The editable-ComboBox
+/// assertion doubles as a canary for the Simple theme actually templating
+/// PART_EditableTextBox - if a theme change drops it, custom feed URLs become untypable.
+///
+[TestFixture]
+public class OpenFromNuGetFeedDialogStructureTests
+{
+ static OpenFromNuGetFeedDialog CreateDialog()
+ => new(settingsService: null, client: new FakeNuGetFeedClient());
+
+ [AvaloniaTest]
+ public void Dialog_Title_And_Captions_Come_From_Localised_Resources()
+ {
+ var dialog = CreateDialog();
+
+ dialog.Title.Should().Be(Resources.NuGetFeedSelectPackage);
+ dialog.FindControl("PrereleaseCheckBox")!.Content
+ .Should().Be(Resources.NuGetFeedShowPrerelease);
+ dialog.FindControl