mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
Opening a package from a feed previously meant downloading the .nupkg by hand and using File > Open (#2313). The new File menu command searches any public V3 feed (editable package-source list, persisted as an MRU in the ILSpy settings), offers the latest 100 versions in a dropdown, and downloads into the NuGet global packages folder so the cache is shared with every other NuGet consumer on the machine and nothing is fetched twice. The cached .nupkg is then opened exactly like the regular Open command. Feed access sits behind INuGetFeedClient so the headless test suite covers search, paging, version selection, download, cancellation, and error surfacing without touching the network. Assisted-by: Claude:claude-fable-5:Claude Codepull/3763/head
20 changed files with 2265 additions and 0 deletions
@ -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; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scriptable in-memory <see cref="INuGetFeedClient"/>: records every call, returns
|
||||||
|
/// configurable results, throws configurable exceptions, and can hold a download open
|
||||||
|
/// on a <see cref="TaskCompletionSource"/> so cancellation paths become testable.
|
||||||
|
/// </summary>
|
||||||
|
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<SearchCall> SearchCalls { get; } = new(); |
||||||
|
public List<VersionsCall> VersionsCalls { get; } = new(); |
||||||
|
public List<DownloadCall> DownloadCalls { get; } = new(); |
||||||
|
|
||||||
|
/// <summary>Computes search results per call; defaults to one well-known package.</summary>
|
||||||
|
public Func<SearchCall, IReadOnlyList<NuGetPackageInfo>> SearchHandler { get; set; } |
||||||
|
= call => new[] { MakePackage("Newtonsoft.Json") }; |
||||||
|
|
||||||
|
public Exception? SearchException { get; set; } |
||||||
|
|
||||||
|
public IReadOnlyList<string> 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; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public TaskCompletionSource<string>? 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<NuGetPackageInfo> MakePage(int count, int offset = 0) |
||||||
|
=> Enumerable.Range(offset, count).Select(i => MakePackage($"Package{i:D3}")).ToArray(); |
||||||
|
|
||||||
|
public Task<IReadOnlyList<NuGetPackageInfo>> 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<IReadOnlyList<NuGetPackageInfo>>(SearchException); |
||||||
|
return Task.FromResult(SearchHandler(call)); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<IReadOnlyList<string>> 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<IReadOnlyList<string>>(VersionsException); |
||||||
|
return Task.FromResult(VersionsToReturn); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<string> 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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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"); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>ErrorMessage</c> instead of escaping. The fake records every call, so the assertions
|
||||||
|
/// pin the exact requests the real NuGet client would receive.
|
||||||
|
/// </summary>
|
||||||
|
[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<string>(); |
||||||
|
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"); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<CheckBox>("PrereleaseCheckBox")!.Content |
||||||
|
.Should().Be(Resources.NuGetFeedShowPrerelease); |
||||||
|
dialog.FindControl<Button>("OpenButton")!.Content |
||||||
|
.Should().Be(Resources.OpenListDialog__Open); |
||||||
|
dialog.FindControl<Button>("CancelButton")!.Content |
||||||
|
.Should().Be(Resources.Cancel); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public void Dialog_Contains_The_Package_Chooser_Controls() |
||||||
|
{ |
||||||
|
var dialog = CreateDialog(); |
||||||
|
|
||||||
|
dialog.FindControl<TextBox>("SearchBox").Should().NotBeNull("packages are found by searching"); |
||||||
|
dialog.FindControl<Button>("RefreshButton").Should().NotBeNull(); |
||||||
|
dialog.FindControl<CheckBox>("PrereleaseCheckBox").Should().NotBeNull(); |
||||||
|
dialog.FindControl<ListBox>("PackagesList").Should().NotBeNull("search hits go into the left-hand list"); |
||||||
|
dialog.FindControl<ComboBox>("VersionsCombo").Should().NotBeNull("the version is picked from a dropdown"); |
||||||
|
dialog.FindControl<Button>("LoadMoreButton").Should().NotBeNull("results are paged"); |
||||||
|
dialog.FindControl<Button>("CancelDownloadButton").Should().NotBeNull("long downloads must be abortable"); |
||||||
|
|
||||||
|
var errorBar = dialog.FindControl<Border>("ErrorBar"); |
||||||
|
errorBar.Should().NotBeNull("feed failures surface in an in-dialog status bar"); |
||||||
|
errorBar!.IsVisible.Should().BeFalse("no error is showing before anything went wrong"); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Feed_ComboBox_Is_Editable_So_Custom_Feed_Urls_Can_Be_Typed() |
||||||
|
{ |
||||||
|
var dialog = CreateDialog(); |
||||||
|
dialog.Show(); |
||||||
|
|
||||||
|
var feedCombo = dialog.FindControl<ComboBox>("FeedCombo"); |
||||||
|
feedCombo.Should().NotBeNull(); |
||||||
|
feedCombo!.IsEditable.Should().BeTrue("users must be able to type arbitrary V3 feed URLs"); |
||||||
|
|
||||||
|
// The editable text box is an optional template part; this pins that the active
|
||||||
|
// theme's ComboBox template actually provides it.
|
||||||
|
await Waiters.WaitForAsync(() => feedCombo.GetVisualDescendants() |
||||||
|
.OfType<TextBox>().Any(t => t.Name == "PART_EditableTextBox")); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Error_Bar_Shows_The_ViewModels_Error_Message() |
||||||
|
{ |
||||||
|
var dialog = CreateDialog(); |
||||||
|
dialog.Show(); |
||||||
|
var vm = (OpenFromNuGetFeedDialogViewModel)dialog.DataContext!; |
||||||
|
|
||||||
|
vm.ErrorMessage = "the feed is on fire"; |
||||||
|
await Waiters.WaitForAsync(() => dialog.FindControl<Border>("ErrorBar")!.IsVisible); |
||||||
|
|
||||||
|
dialog.FindControl<Border>("ErrorBar")!.GetVisualDescendants().OfType<TextBlock>() |
||||||
|
.Should().Contain(t => t.Text == "the feed is on fire"); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Clear_Button_Lives_Inside_The_Search_Box_And_Only_Shows_While_It_Has_Text() |
||||||
|
{ |
||||||
|
var dialog = CreateDialog(); |
||||||
|
dialog.Show(); |
||||||
|
|
||||||
|
var searchBox = dialog.FindControl<TextBox>("SearchBox")!; |
||||||
|
var clearButton = searchBox.InnerRightContent.Should().BeOfType<Button>( |
||||||
|
"the clear affordance sits inside the search box, like the Search pane's").Subject; |
||||||
|
clearButton.IsVisible.Should().BeFalse("there is nothing to clear in an empty box"); |
||||||
|
|
||||||
|
var vm = (OpenFromNuGetFeedDialogViewModel)dialog.DataContext!; |
||||||
|
vm.SearchText = "newtonsoft"; |
||||||
|
await Waiters.WaitForAsync(() => clearButton.IsVisible); |
||||||
|
|
||||||
|
clearButton.Command.Should().BeSameAs(vm.ClearSearchCommand); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Selecting_A_Package_Preselects_The_Latest_Version_In_The_Dropdown() |
||||||
|
{ |
||||||
|
var client = new FakeNuGetFeedClient(); |
||||||
|
client.VersionsToReturn = new[] { "13.0.3", "13.0.2", "12.0.1" }; |
||||||
|
var dialog = new OpenFromNuGetFeedDialog(settingsService: null, client: client); |
||||||
|
dialog.Show(); |
||||||
|
|
||||||
|
var vm = (OpenFromNuGetFeedDialogViewModel)dialog.DataContext!; |
||||||
|
await Waiters.WaitForAsync(() => vm.Packages.Count > 0); |
||||||
|
vm.SelectedPackage = vm.Packages[0]; |
||||||
|
|
||||||
|
var combo = dialog.FindControl<ComboBox>("VersionsCombo")!; |
||||||
|
await Waiters.WaitForAsync(() => Equals(combo.SelectedItem, "13.0.3"), |
||||||
|
TimeSpan.FromSeconds(5)); |
||||||
|
combo.SelectedIndex.Should().Be(0, "the newest version leads the list and starts selected"); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Opening_The_Dialog_Lists_The_Feeds_Default_Packages() |
||||||
|
{ |
||||||
|
var client = new FakeNuGetFeedClient(); |
||||||
|
var dialog = new OpenFromNuGetFeedDialog(settingsService: null, client: client); |
||||||
|
dialog.Show(); |
||||||
|
|
||||||
|
await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0); |
||||||
|
client.SearchCalls[0].SearchText.Should().BeEmpty( |
||||||
|
"the dialog must not open empty - the feed's default listing gives the user something to browse"); |
||||||
|
|
||||||
|
var vm = (OpenFromNuGetFeedDialogViewModel)dialog.DataContext!; |
||||||
|
await Waiters.WaitForAsync(() => vm.Packages.Count > 0); |
||||||
|
var list = dialog.FindControl<ListBox>("PackagesList")!; |
||||||
|
list.ItemCount.Should().Be(vm.Packages.Count, "the list shows exactly the search hits"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
// 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.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
|
||||||
|
namespace ILSpy.NuGetFeeds |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// One search hit from a NuGet feed, reduced to what the package-chooser dialog
|
||||||
|
/// displays. Deliberately free of NuGet.* types so view models and tests don't
|
||||||
|
/// take a dependency on the NuGet client libraries.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record NuGetPackageInfo( |
||||||
|
string Id, |
||||||
|
string LatestVersion, |
||||||
|
string? Description, |
||||||
|
string? Authors, |
||||||
|
string? IconUrl, |
||||||
|
string? LicenseUrl, |
||||||
|
string? ProjectUrl, |
||||||
|
string? Tags, |
||||||
|
long? DownloadCount, |
||||||
|
DateTimeOffset? Published); |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feed operations the "Open from NuGet feed" dialog needs. The production
|
||||||
|
/// implementation (<see cref="NuGetFeedClient"/>) talks to NuGet V3 feeds;
|
||||||
|
/// tests substitute a fake so no network is involved.
|
||||||
|
/// </summary>
|
||||||
|
public interface INuGetFeedClient |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Searches <paramref name="feedUrl"/> for packages matching <paramref name="searchText"/>
|
||||||
|
/// (an empty string yields the feed's default listing, like the NuGet gallery front page).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<NuGetPackageInfo>> SearchAsync( |
||||||
|
string feedUrl, string searchText, bool includePrerelease, |
||||||
|
int skip, int take, CancellationToken cancellationToken); |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns at most <paramref name="maxCount"/> versions of <paramref name="packageId"/>,
|
||||||
|
/// newest first, excluding prerelease versions unless <paramref name="includePrerelease"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> GetVersionsAsync( |
||||||
|
string feedUrl, string packageId, bool includePrerelease, |
||||||
|
int maxCount, CancellationToken cancellationToken); |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads the package into the NuGet global packages folder (reusing an already
|
||||||
|
/// cached copy) and returns the absolute path of the stored .nupkg file.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> DownloadToGlobalPackagesFolderAsync( |
||||||
|
string feedUrl, string packageId, string version, |
||||||
|
CancellationToken cancellationToken); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,134 @@ |
|||||||
|
// 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.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
|
||||||
|
using NuGet.Common; |
||||||
|
using NuGet.Configuration; |
||||||
|
using NuGet.Packaging; |
||||||
|
using NuGet.Packaging.Core; |
||||||
|
using NuGet.Packaging.Signing; |
||||||
|
using NuGet.Protocol; |
||||||
|
using NuGet.Protocol.Core.Types; |
||||||
|
using NuGet.Versioning; |
||||||
|
|
||||||
|
namespace ILSpy.NuGetFeeds |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// <see cref="INuGetFeedClient"/> backed by the NuGet client libraries, talking to
|
||||||
|
/// V3 feeds. Downloads land in the user's NuGet global packages folder (honoring
|
||||||
|
/// NuGet.Config / NUGET_PACKAGES overrides), so packages fetched here are shared
|
||||||
|
/// with every other NuGet consumer on the machine and never downloaded twice.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NuGetFeedClient : INuGetFeedClient |
||||||
|
{ |
||||||
|
static readonly ILogger Logger = NullLogger.Instance; |
||||||
|
|
||||||
|
readonly ConcurrentDictionary<string, SourceRepository> repositories = |
||||||
|
new(StringComparer.OrdinalIgnoreCase); |
||||||
|
readonly SourceCacheContext cache = new(); |
||||||
|
|
||||||
|
SourceRepository GetRepository(string feedUrl) |
||||||
|
=> repositories.GetOrAdd(feedUrl, url => Repository.Factory.GetCoreV3(url)); |
||||||
|
|
||||||
|
public async Task<IReadOnlyList<NuGetPackageInfo>> SearchAsync( |
||||||
|
string feedUrl, string searchText, bool includePrerelease, |
||||||
|
int skip, int take, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
var search = await GetRepository(feedUrl) |
||||||
|
.GetResourceAsync<PackageSearchResource>(cancellationToken).ConfigureAwait(false) |
||||||
|
?? throw new InvalidOperationException($"The feed '{feedUrl}' does not support searching."); |
||||||
|
var results = await search.SearchAsync( |
||||||
|
searchText, new SearchFilter(includePrerelease), skip, take, |
||||||
|
Logger, cancellationToken).ConfigureAwait(false); |
||||||
|
return results.Select(m => new NuGetPackageInfo( |
||||||
|
Id: m.Identity.Id, |
||||||
|
LatestVersion: m.Identity.Version.ToNormalizedString(), |
||||||
|
Description: m.Description, |
||||||
|
Authors: m.Authors, |
||||||
|
IconUrl: m.IconUrl?.ToString(), |
||||||
|
LicenseUrl: m.LicenseUrl?.ToString(), |
||||||
|
ProjectUrl: m.ProjectUrl?.ToString(), |
||||||
|
Tags: m.Tags, |
||||||
|
DownloadCount: m.DownloadCount, |
||||||
|
Published: m.Published)).ToArray(); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> GetVersionsAsync( |
||||||
|
string feedUrl, string packageId, bool includePrerelease, |
||||||
|
int maxCount, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
var byId = await GetRepository(feedUrl) |
||||||
|
.GetResourceAsync<FindPackageByIdResource>(cancellationToken).ConfigureAwait(false); |
||||||
|
var versions = await byId.GetAllVersionsAsync( |
||||||
|
packageId, cache, Logger, cancellationToken).ConfigureAwait(false); |
||||||
|
return versions |
||||||
|
.Where(v => includePrerelease || !v.IsPrerelease) |
||||||
|
.OrderByDescending(v => v) |
||||||
|
.Take(maxCount) |
||||||
|
.Select(v => v.ToNormalizedString()) |
||||||
|
.ToArray(); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<string> DownloadToGlobalPackagesFolderAsync( |
||||||
|
string feedUrl, string packageId, string version, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
var identity = new PackageIdentity(packageId, NuGetVersion.Parse(version)); |
||||||
|
var nugetSettings = Settings.LoadDefaultSettings(root: null); |
||||||
|
string packagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings); |
||||||
|
string nupkgPath = new VersionFolderPathResolver(packagesFolder) |
||||||
|
.GetPackageFilePath(identity.Id, identity.Version); |
||||||
|
|
||||||
|
// Already installed (by ILSpy or any other NuGet consumer): reuse it.
|
||||||
|
var cached = GlobalPackagesFolderUtility.GetPackage(identity, packagesFolder); |
||||||
|
if (cached != null) |
||||||
|
{ |
||||||
|
cached.Dispose(); |
||||||
|
if (File.Exists(nupkgPath)) |
||||||
|
return nupkgPath; |
||||||
|
} |
||||||
|
|
||||||
|
var byId = await GetRepository(feedUrl) |
||||||
|
.GetResourceAsync<FindPackageByIdResource>(cancellationToken).ConfigureAwait(false); |
||||||
|
using var packageStream = new MemoryStream(); |
||||||
|
bool found = await byId.CopyNupkgToStreamAsync( |
||||||
|
identity.Id, identity.Version, packageStream, cache, |
||||||
|
Logger, cancellationToken).ConfigureAwait(false); |
||||||
|
if (!found) |
||||||
|
{ |
||||||
|
throw new InvalidOperationException( |
||||||
|
$"Package {packageId} {version} was not found on '{feedUrl}'."); |
||||||
|
} |
||||||
|
|
||||||
|
packageStream.Position = 0; |
||||||
|
var clientPolicy = ClientPolicyContext.GetClientPolicy(nugetSettings, Logger); |
||||||
|
var added = await GlobalPackagesFolderUtility.AddPackageAsync( |
||||||
|
feedUrl, identity, packageStream, packagesFolder, parentId: Guid.Empty, |
||||||
|
clientPolicy, Logger, cancellationToken).ConfigureAwait(false); |
||||||
|
added.Dispose(); |
||||||
|
return nupkgPath; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,114 @@ |
|||||||
|
// 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.Xml.Linq; |
||||||
|
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
|
||||||
|
using ICSharpCode.ILSpyX.Settings; |
||||||
|
|
||||||
|
namespace ILSpy.NuGetFeeds |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Persisted state of the "Open from NuGet feed" dialog: the MRU list of feed URLs the
|
||||||
|
/// user has searched (always including the official nuget.org V3 feed), the feed that
|
||||||
|
/// was active when the dialog was last used, and the pre-release toggle. The selected
|
||||||
|
/// feed is guaranteed to be one of <see cref="Feeds"/>, and the list is bounded by
|
||||||
|
/// <see cref="MaxFeeds"/> with the default feed exempt from eviction.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class NuGetFeedSettings : ObservableObject, ISettingsSection |
||||||
|
{ |
||||||
|
public const string DefaultFeed = "https://api.nuget.org/v3/index.json"; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upper bound of the feed MRU. Eviction drops the oldest custom feed first; the
|
||||||
|
/// default feed never leaves the list.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxFeeds = 10; |
||||||
|
|
||||||
|
readonly List<string> feeds = new() { DefaultFeed }; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
string selectedFeed = DefaultFeed; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
bool includePrerelease; |
||||||
|
|
||||||
|
public XName SectionName => "NuGetFeedSettings"; |
||||||
|
|
||||||
|
public IReadOnlyList<string> Feeds => feeds; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <paramref name="feedUrl"/> to the feed MRU (most recent last). Blank input
|
||||||
|
/// and case-insensitive duplicates are ignored; when the cap is hit, the oldest
|
||||||
|
/// custom feed is evicted.
|
||||||
|
/// </summary>
|
||||||
|
public void RememberFeed(string? feedUrl) |
||||||
|
{ |
||||||
|
feedUrl = feedUrl?.Trim(); |
||||||
|
if (string.IsNullOrEmpty(feedUrl)) |
||||||
|
return; |
||||||
|
if (feeds.Any(f => string.Equals(f, feedUrl, StringComparison.OrdinalIgnoreCase))) |
||||||
|
return; |
||||||
|
feeds.Add(feedUrl); |
||||||
|
while (feeds.Count > MaxFeeds) |
||||||
|
{ |
||||||
|
int oldestCustom = feeds.FindIndex(f => !string.Equals(f, DefaultFeed, StringComparison.OrdinalIgnoreCase)); |
||||||
|
if (oldestCustom < 0) |
||||||
|
break; |
||||||
|
feeds.RemoveAt(oldestCustom); |
||||||
|
} |
||||||
|
OnPropertyChanged(nameof(Feeds)); |
||||||
|
} |
||||||
|
|
||||||
|
public void LoadFromXml(XElement section) |
||||||
|
{ |
||||||
|
feeds.Clear(); |
||||||
|
feeds.Add(DefaultFeed); |
||||||
|
foreach (var feed in section.Element("Feeds")?.Elements("Feed") ?? Enumerable.Empty<XElement>()) |
||||||
|
RememberFeed(feed.Value); |
||||||
|
|
||||||
|
var selected = ((string?)section.Element("SelectedFeed"))?.Trim(); |
||||||
|
SelectedFeed = feeds.FirstOrDefault(f => string.Equals(f, selected, StringComparison.OrdinalIgnoreCase)) |
||||||
|
?? DefaultFeed; |
||||||
|
|
||||||
|
bool prerelease; |
||||||
|
try |
||||||
|
{ |
||||||
|
prerelease = (bool?)section.Element("IncludePrerelease") ?? false; |
||||||
|
} |
||||||
|
catch (FormatException) |
||||||
|
{ |
||||||
|
// Malformed boolean in a hand-edited settings file; fall back to stable-only.
|
||||||
|
prerelease = false; |
||||||
|
} |
||||||
|
IncludePrerelease = prerelease; |
||||||
|
} |
||||||
|
|
||||||
|
public XElement SaveToXml() |
||||||
|
{ |
||||||
|
return new XElement(SectionName, |
||||||
|
new XElement("Feeds", feeds.Select(f => new XElement("Feed", f))), |
||||||
|
new XElement("SelectedFeed", SelectedFeed), |
||||||
|
new XElement("IncludePrerelease", IncludePrerelease)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,285 @@ |
|||||||
|
// 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.ObjectModel; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
|
||||||
|
using ILSpy.NuGetFeeds; |
||||||
|
|
||||||
|
namespace ILSpy.ViewModels |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Drives the "Open from NuGet feed" dialog: debounced search against the selected
|
||||||
|
/// V3 feed, skip/take paging, a version list for the selected package, and a
|
||||||
|
/// cancellable download into the global packages folder. All feed I/O goes through
|
||||||
|
/// <see cref="INuGetFeedClient"/>; feed errors land in <see cref="ErrorMessage"/>.
|
||||||
|
/// The dialog closes itself when <see cref="CloseRequested"/> fires with the path
|
||||||
|
/// of the downloaded .nupkg.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class OpenFromNuGetFeedDialogViewModel : ViewModelBase |
||||||
|
{ |
||||||
|
public const int PageSize = 25; |
||||||
|
public const int MaxVersions = 100; |
||||||
|
|
||||||
|
readonly INuGetFeedClient client; |
||||||
|
readonly NuGetFeedSettings settings; |
||||||
|
readonly TimeSpan debounceDelay; |
||||||
|
|
||||||
|
CancellationTokenSource? searchCts; |
||||||
|
// Distinguishes the most recent search from superseded ones: a stale search must
|
||||||
|
// neither publish its results nor flip IsSearching off under the active one.
|
||||||
|
int searchGeneration; |
||||||
|
|
||||||
|
CancellationTokenSource? versionsCts; |
||||||
|
int versionsGeneration; |
||||||
|
|
||||||
|
CancellationTokenSource? downloadCts; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
string searchText = string.Empty; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
bool includePrerelease; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
string selectedFeedUrl; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
NuGetPackageInfo? selectedPackage; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyCanExecuteChangedFor(nameof(OpenPackageCommand))] |
||||||
|
string? selectedVersion; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
bool isSearching; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
[NotifyCanExecuteChangedFor(nameof(OpenPackageCommand))] |
||||||
|
bool isDownloading; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
bool canLoadMore; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
string? errorMessage; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
string? downloadedPackagePath; |
||||||
|
|
||||||
|
public ObservableCollection<string> FeedUrls { get; } |
||||||
|
public ObservableCollection<NuGetPackageInfo> Packages { get; } = new(); |
||||||
|
public ObservableCollection<string> Versions { get; } = new(); |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised with the path of the downloaded .nupkg once it is ready to be opened;
|
||||||
|
/// the view responds by closing the dialog with that path as its result.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string?>? CloseRequested; |
||||||
|
|
||||||
|
public OpenFromNuGetFeedDialogViewModel( |
||||||
|
INuGetFeedClient client, NuGetFeedSettings settings, TimeSpan? debounceDelay = null) |
||||||
|
{ |
||||||
|
this.client = client; |
||||||
|
this.settings = settings; |
||||||
|
this.debounceDelay = debounceDelay ?? TimeSpan.FromMilliseconds(300); |
||||||
|
FeedUrls = new ObservableCollection<string>(settings.Feeds); |
||||||
|
selectedFeedUrl = settings.SelectedFeed; |
||||||
|
includePrerelease = settings.IncludePrerelease; |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSearchTextChanged(string value) => StartSearch(debounce: true); |
||||||
|
|
||||||
|
partial void OnIncludePrereleaseChanged(bool value) |
||||||
|
{ |
||||||
|
settings.IncludePrerelease = value; |
||||||
|
StartSearch(debounce: true); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedFeedUrlChanged(string value) => StartSearch(debounce: true); |
||||||
|
|
||||||
|
partial void OnSelectedPackageChanged(NuGetPackageInfo? value) |
||||||
|
=> _ = LoadVersionsAsync(value); |
||||||
|
|
||||||
|
partial void OnSelectedVersionChanged(string? value) |
||||||
|
{ |
||||||
|
// A ComboBox resets its SelectedItem to null when its items change under it;
|
||||||
|
// never sit on an empty selection while versions are available - the latest
|
||||||
|
// (first) version is always the default.
|
||||||
|
if (value == null && Versions.Count > 0) |
||||||
|
SelectedVersion = Versions[0]; |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
void Refresh() => StartSearch(debounce: false); |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
void ClearSearch() |
||||||
|
{ |
||||||
|
if (SearchText.Length == 0) |
||||||
|
StartSearch(debounce: false); |
||||||
|
else |
||||||
|
SearchText = string.Empty; |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
void LoadMore() => StartSearch(debounce: false, append: true); |
||||||
|
|
||||||
|
bool CanOpenPackage => SelectedVersion != null && !IsDownloading; |
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanOpenPackage))] |
||||||
|
async Task OpenPackage() |
||||||
|
{ |
||||||
|
var package = SelectedPackage; |
||||||
|
var version = SelectedVersion; |
||||||
|
if (package == null || version == null) |
||||||
|
return; |
||||||
|
|
||||||
|
downloadCts?.Cancel(); |
||||||
|
var cts = downloadCts = new CancellationTokenSource(); |
||||||
|
IsDownloading = true; |
||||||
|
try |
||||||
|
{ |
||||||
|
string path = await client.DownloadToGlobalPackagesFolderAsync( |
||||||
|
SelectedFeedUrl?.Trim() ?? string.Empty, package.Id, version, cts.Token); |
||||||
|
ErrorMessage = null; |
||||||
|
DownloadedPackagePath = path; |
||||||
|
CloseRequested?.Invoke(path); |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
// User hit Cancel (or closed the dialog); not an error, no close.
|
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
ErrorMessage = ex.Message; |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
IsDownloading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
void CancelDownload() => downloadCts?.Cancel(); |
||||||
|
|
||||||
|
/// <summary>Cancels every in-flight feed operation; called when the dialog closes.</summary>
|
||||||
|
public void CancelAllOperations() |
||||||
|
{ |
||||||
|
searchCts?.Cancel(); |
||||||
|
versionsCts?.Cancel(); |
||||||
|
downloadCts?.Cancel(); |
||||||
|
} |
||||||
|
|
||||||
|
async Task LoadVersionsAsync(NuGetPackageInfo? package) |
||||||
|
{ |
||||||
|
versionsCts?.Cancel(); |
||||||
|
var cts = versionsCts = new CancellationTokenSource(); |
||||||
|
int generation = ++versionsGeneration; |
||||||
|
Versions.Clear(); |
||||||
|
SelectedVersion = null; |
||||||
|
if (package == null) |
||||||
|
return; |
||||||
|
try |
||||||
|
{ |
||||||
|
var versions = await client.GetVersionsAsync( |
||||||
|
SelectedFeedUrl?.Trim() ?? string.Empty, package.Id, IncludePrerelease, |
||||||
|
MaxVersions, cts.Token); |
||||||
|
if (generation != versionsGeneration) |
||||||
|
return; |
||||||
|
foreach (var version in versions) |
||||||
|
Versions.Add(version); |
||||||
|
SelectedVersion = Versions.FirstOrDefault(); |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
// Selection moved on or the dialog closed.
|
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
if (generation == versionsGeneration) |
||||||
|
ErrorMessage = ex.Message; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
void StartSearch(bool debounce, bool append = false) |
||||||
|
=> _ = RunSearchAsync(debounce, append); |
||||||
|
|
||||||
|
async Task RunSearchAsync(bool debounce, bool append) |
||||||
|
{ |
||||||
|
searchCts?.Cancel(); |
||||||
|
var cts = searchCts = new CancellationTokenSource(); |
||||||
|
int generation = ++searchGeneration; |
||||||
|
try |
||||||
|
{ |
||||||
|
if (debounce && debounceDelay > TimeSpan.Zero) |
||||||
|
await Task.Delay(debounceDelay, cts.Token); |
||||||
|
|
||||||
|
IsSearching = true; |
||||||
|
string feed = SelectedFeedUrl?.Trim() ?? string.Empty; |
||||||
|
int skip = append ? Packages.Count : 0; |
||||||
|
var results = await client.SearchAsync( |
||||||
|
feed, SearchText, IncludePrerelease, skip, PageSize, cts.Token); |
||||||
|
if (generation != searchGeneration) |
||||||
|
return; |
||||||
|
|
||||||
|
ErrorMessage = null; |
||||||
|
if (!append) |
||||||
|
{ |
||||||
|
Packages.Clear(); |
||||||
|
SelectedPackage = null; |
||||||
|
} |
||||||
|
foreach (var package in results) |
||||||
|
Packages.Add(package); |
||||||
|
CanLoadMore = results.Count == PageSize; |
||||||
|
|
||||||
|
// Only a feed that actually answered earns a spot in the persisted MRU.
|
||||||
|
settings.RememberFeed(feed); |
||||||
|
settings.SelectedFeed = settings.Feeds.FirstOrDefault( |
||||||
|
f => string.Equals(f, feed, StringComparison.OrdinalIgnoreCase)) ?? feed; |
||||||
|
if (!FeedUrls.Contains(settings.SelectedFeed, StringComparer.OrdinalIgnoreCase)) |
||||||
|
FeedUrls.Add(settings.SelectedFeed); |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
// Superseded by a newer search or the dialog closed.
|
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
if (generation == searchGeneration) |
||||||
|
{ |
||||||
|
ErrorMessage = ex.Message; |
||||||
|
Packages.Clear(); |
||||||
|
SelectedPackage = null; |
||||||
|
CanLoadMore = false; |
||||||
|
} |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
if (generation == searchGeneration) |
||||||
|
IsSearching = false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,160 @@ |
|||||||
|
<Window xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:img="using:ILSpy.Images" |
||||||
|
xmlns:nf="using:ILSpy.NuGetFeeds" |
||||||
|
xmlns:vm="using:ILSpy.ViewModels" |
||||||
|
x:Class="ILSpy.Views.OpenFromNuGetFeedDialog" |
||||||
|
x:DataType="vm:OpenFromNuGetFeedDialogViewModel" |
||||||
|
Width="1000" Height="650" MinWidth="700" MinHeight="400" |
||||||
|
WindowStartupLocation="CenterOwner" |
||||||
|
CanResize="True"> |
||||||
|
<Grid Margin="12,8" RowDefinitions="Auto,*,Auto,Auto" RowSpacing="8"> |
||||||
|
|
||||||
|
<!-- Top bar: search + refresh + prerelease toggle, package source on the right. --> |
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="2*,Auto,Auto,Auto,Auto,3*" ColumnSpacing="8"> |
||||||
|
<TextBox Grid.Column="0" Name="SearchBox" Text="{Binding SearchText, Mode=TwoWay}"> |
||||||
|
<TextBox.InnerRightContent> |
||||||
|
<!-- Inline clear affordance, mirroring SearchPane's: borderless, transparent, |
||||||
|
and only visible while there is something to clear. --> |
||||||
|
<Button Name="ClearSearchButton" |
||||||
|
IsVisible="{Binding SearchText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||||
|
Background="Transparent" BorderThickness="0" |
||||||
|
Padding="4,0" Margin="0" |
||||||
|
Command="{Binding ClearSearchCommand}"> |
||||||
|
<TextBlock Text="✕" FontSize="11" VerticalAlignment="Center" /> |
||||||
|
</Button> |
||||||
|
</TextBox.InnerRightContent> |
||||||
|
</TextBox> |
||||||
|
<Button Grid.Column="1" Name="RefreshButton" Command="{Binding RefreshCommand}"> |
||||||
|
<Image Width="16" Height="16" Source="{x:Static img:Images.Refresh}" /> |
||||||
|
</Button> |
||||||
|
<CheckBox Grid.Column="2" Name="PrereleaseCheckBox" |
||||||
|
IsChecked="{Binding IncludePrerelease, Mode=TwoWay}" /> |
||||||
|
<Label Grid.Column="4" Name="PackageSourceLabel" Target="{Binding #FeedCombo}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<ComboBox Grid.Column="5" Name="FeedCombo" IsEditable="True" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
ItemsSource="{Binding FeedUrls}" |
||||||
|
Text="{Binding SelectedFeedUrl, Mode=TwoWay}" /> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- Main area: results list left, detail pane right. --> |
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="3*,2*" ColumnSpacing="12"> |
||||||
|
<Grid Grid.Column="0" RowDefinitions="*"> |
||||||
|
<ListBox Grid.Row="0" Name="PackagesList" |
||||||
|
ItemsSource="{Binding Packages}" |
||||||
|
SelectedItem="{Binding SelectedPackage, Mode=TwoWay}"> |
||||||
|
<ListBox.ItemTemplate> |
||||||
|
<DataTemplate x:DataType="nf:NuGetPackageInfo"> |
||||||
|
<!-- MinHeight reserves the two description lines (MaxLines below caps |
||||||
|
them), so rows stay uniform whether a package has a long, short, |
||||||
|
or missing description. --> |
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="8" Margin="2" |
||||||
|
MinHeight="52"> |
||||||
|
<Image Grid.Column="0" Width="32" Height="32" VerticalAlignment="Top" |
||||||
|
Source="{x:Static img:Images.NuGet}" /> |
||||||
|
<StackPanel Grid.Column="1" Spacing="2"> |
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4"> |
||||||
|
<TextBlock FontWeight="Bold" Text="{Binding Id}" /> |
||||||
|
<TextBlock Opacity="0.75" |
||||||
|
IsVisible="{Binding Authors, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" |
||||||
|
Text="{Binding Authors, StringFormat='by {0}'}" /> |
||||||
|
</StackPanel> |
||||||
|
<TextBlock Opacity="0.75" TextWrapping="Wrap" MaxLines="2" |
||||||
|
TextTrimming="CharacterEllipsis" |
||||||
|
Text="{Binding Description}" /> |
||||||
|
</StackPanel> |
||||||
|
<TextBlock Grid.Column="2" VerticalAlignment="Top" |
||||||
|
Text="{Binding LatestVersion, StringFormat='v{0}'}" /> |
||||||
|
</Grid> |
||||||
|
</DataTemplate> |
||||||
|
</ListBox.ItemTemplate> |
||||||
|
</ListBox> |
||||||
|
<ProgressBar Grid.Row="0" Name="SearchProgressBar" Height="6" |
||||||
|
VerticalAlignment="Bottom" IsIndeterminate="True" |
||||||
|
IsVisible="{Binding IsSearching}" /> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<ScrollViewer Grid.Column="1" |
||||||
|
IsVisible="{Binding SelectedPackage, Converter={x:Static ObjectConverters.IsNotNull}}"> |
||||||
|
<StackPanel Name="DetailPane" Spacing="8" Margin="0,0,4,0"> |
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8"> |
||||||
|
<Image Width="32" Height="32" Source="{x:Static img:Images.NuGet}" /> |
||||||
|
<TextBlock FontSize="20" FontWeight="Bold" VerticalAlignment="Center" |
||||||
|
Text="{Binding SelectedPackage.Id}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"> |
||||||
|
<Label Name="VersionLabel" Target="{Binding #VersionsCombo}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<ComboBox Name="VersionsCombo" MinWidth="150" |
||||||
|
ItemsSource="{Binding Versions}" |
||||||
|
SelectedItem="{Binding SelectedVersion, Mode=TwoWay}" /> |
||||||
|
<Button Name="OpenButton" IsDefault="True" MinWidth="72" |
||||||
|
Command="{Binding OpenPackageCommand}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" |
||||||
|
IsVisible="{Binding IsDownloading}"> |
||||||
|
<TextBlock Name="DownloadingLabel" VerticalAlignment="Center" /> |
||||||
|
<ProgressBar Name="DownloadProgressBar" Width="160" Height="8" |
||||||
|
IsIndeterminate="True" /> |
||||||
|
<Button Name="CancelDownloadButton" |
||||||
|
Command="{Binding CancelDownloadCommand}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<TextBlock Name="DescriptionHeaderLabel" FontWeight="Bold" /> |
||||||
|
<TextBlock TextWrapping="Wrap" Text="{Binding SelectedPackage.Description}" /> |
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12" |
||||||
|
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="1" TextWrapping="Wrap" |
||||||
|
Text="{Binding SelectedPackage.Authors}" /> |
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Name="LicenseLabel" FontWeight="Bold" /> |
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1" Name="LicenseLink" |
||||||
|
TextDecorations="Underline" Foreground="#1B6EC2" Cursor="Hand" |
||||||
|
TextWrapping="Wrap" |
||||||
|
Text="{Binding SelectedPackage.LicenseUrl}" /> |
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Name="PublishedLabel" FontWeight="Bold" /> |
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1" |
||||||
|
Text="{Binding SelectedPackage.Published, StringFormat='{}{0:D}'}" /> |
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Name="ProjectUrlLabel" FontWeight="Bold" /> |
||||||
|
<TextBlock Grid.Row="3" Grid.Column="1" Name="ProjectUrlLink" |
||||||
|
TextDecorations="Underline" Foreground="#1B6EC2" Cursor="Hand" |
||||||
|
TextWrapping="Wrap" |
||||||
|
Text="{Binding SelectedPackage.ProjectUrl}" /> |
||||||
|
|
||||||
|
<TextBlock Grid.Row="4" Grid.Column="0" Name="DownloadsLabel" FontWeight="Bold" /> |
||||||
|
<TextBlock Grid.Row="4" Grid.Column="1" |
||||||
|
Text="{Binding SelectedPackage.DownloadCount, StringFormat='{}{0:N0}'}" /> |
||||||
|
|
||||||
|
<TextBlock Grid.Row="5" Grid.Column="0" Name="TagsLabel" FontWeight="Bold" /> |
||||||
|
<TextBlock Grid.Row="5" Grid.Column="1" TextWrapping="Wrap" |
||||||
|
Text="{Binding SelectedPackage.Tags}" /> |
||||||
|
</Grid> |
||||||
|
</StackPanel> |
||||||
|
</ScrollViewer> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<!-- Error bar: status strip for feed/download failures. Fixed beige |
||||||
|
background with explicit black text so it stays readable in dark themes. --> |
||||||
|
<Border Grid.Row="2" Name="ErrorBar" Background="#FFF4CE" CornerRadius="2" Padding="8,4" |
||||||
|
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> |
||||||
|
<TextBlock Name="ErrorText" Foreground="Black" TextWrapping="Wrap" |
||||||
|
Text="{Binding ErrorMessage}" /> |
||||||
|
</Border> |
||||||
|
|
||||||
|
<!-- Bottom row: "Load more" hugs the left edge of the results list above it; |
||||||
|
Cancel stays at the dialog's right edge on the same row. --> |
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,Auto"> |
||||||
|
<Button Grid.Column="0" Name="LoadMoreButton" HorizontalAlignment="Left" |
||||||
|
Command="{Binding LoadMoreCommand}" |
||||||
|
IsVisible="{Binding CanLoadMore}" /> |
||||||
|
<Button Grid.Column="1" Name="CancelButton" IsCancel="True" MinWidth="72" /> |
||||||
|
</Grid> |
||||||
|
</Grid> |
||||||
|
</Window> |
||||||
@ -0,0 +1,123 @@ |
|||||||
|
// 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.Diagnostics; |
||||||
|
|
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Input; |
||||||
|
using Avalonia.Markup.Xaml; |
||||||
|
|
||||||
|
using ILSpy.NuGetFeeds; |
||||||
|
using ILSpy.ViewModels; |
||||||
|
|
||||||
|
// Alias the WPF-shared Resources class — Window inherits an IResourceDictionary Resources
|
||||||
|
// property that would otherwise shadow ICSharpCode.ILSpy.Properties.Resources, turning every
|
||||||
|
// `Resources.X` into an IResourceDictionary indexer lookup that doesn't compile.
|
||||||
|
using Loc = ICSharpCode.ILSpy.Properties.Resources; |
||||||
|
|
||||||
|
namespace ILSpy.Views |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// "Open from NuGet feed" package-chooser dialog:
|
||||||
|
/// search a V3 feed (editable package-source ComboBox, pre-release toggle, paged
|
||||||
|
/// results), pick a version from a dropdown, and Open downloads the package into the
|
||||||
|
/// global packages folder. Closes with the absolute path of the cached .nupkg, or
|
||||||
|
/// null when cancelled; all behavior lives in
|
||||||
|
/// <see cref="OpenFromNuGetFeedDialogViewModel"/>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class OpenFromNuGetFeedDialog : Window |
||||||
|
{ |
||||||
|
readonly OpenFromNuGetFeedDialogViewModel viewModel; |
||||||
|
|
||||||
|
// 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()) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public OpenFromNuGetFeedDialog( |
||||||
|
SettingsService? settingsService, |
||||||
|
INuGetFeedClient client, |
||||||
|
TimeSpan? debounceDelay = null) |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
|
||||||
|
var settings = settingsService?.GetSettings<NuGetFeedSettings>() ?? new NuGetFeedSettings(); |
||||||
|
viewModel = new OpenFromNuGetFeedDialogViewModel(client, settings, debounceDelay); |
||||||
|
DataContext = viewModel; |
||||||
|
|
||||||
|
Title = Loc.NuGetFeedSelectPackage; |
||||||
|
var searchBox = this.FindControl<TextBox>("SearchBox")!; |
||||||
|
searchBox.PlaceholderText = Loc.NuGetFeedSearchWatermark; |
||||||
|
ToolTip.SetTip(this.FindControl<Button>("RefreshButton")!, Loc.NuGetFeedRefresh); |
||||||
|
this.FindControl<CheckBox>("PrereleaseCheckBox")!.Content = Loc.NuGetFeedShowPrerelease; |
||||||
|
this.FindControl<Label>("PackageSourceLabel")!.Content = Loc.NuGetFeedPackageSource; |
||||||
|
this.FindControl<Button>("LoadMoreButton")!.Content = Loc.NuGetFeedLoadMore; |
||||||
|
this.FindControl<Label>("VersionLabel")!.Content = Loc.NuGetFeedVersion; |
||||||
|
this.FindControl<Button>("OpenButton")!.Content = Loc.OpenListDialog__Open; |
||||||
|
this.FindControl<TextBlock>("DownloadingLabel")!.Text = Loc.NuGetFeedDownloading; |
||||||
|
this.FindControl<Button>("CancelDownloadButton")!.Content = Loc.Cancel; |
||||||
|
this.FindControl<TextBlock>("DescriptionHeaderLabel")!.Text = Loc.NuGetFeedDescription; |
||||||
|
this.FindControl<TextBlock>("AuthorsLabel")!.Text = Loc.NuGetFeedAuthors; |
||||||
|
this.FindControl<TextBlock>("LicenseLabel")!.Text = Loc.NuGetFeedLicense; |
||||||
|
this.FindControl<TextBlock>("PublishedLabel")!.Text = Loc.NuGetFeedPublished; |
||||||
|
this.FindControl<TextBlock>("ProjectUrlLabel")!.Text = Loc.NuGetFeedProjectUrl; |
||||||
|
this.FindControl<TextBlock>("DownloadsLabel")!.Text = Loc.NuGetFeedDownloads; |
||||||
|
this.FindControl<TextBlock>("TagsLabel")!.Text = Loc.NuGetFeedTags; |
||||||
|
var cancelButton = this.FindControl<Button>("CancelButton")!; |
||||||
|
cancelButton.Content = Loc.Cancel; |
||||||
|
|
||||||
|
// Enter searches immediately, skipping the typing debounce.
|
||||||
|
searchBox.KeyDown += (_, e) => { |
||||||
|
if (e.Key == Key.Enter) |
||||||
|
{ |
||||||
|
viewModel.RefreshCommand.Execute(null); |
||||||
|
e.Handled = true; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
this.FindControl<TextBlock>("LicenseLink")!.PointerReleased += |
||||||
|
(s, _) => OpenInBrowser(((TextBlock)s!).Text); |
||||||
|
this.FindControl<TextBlock>("ProjectUrlLink")!.PointerReleased += |
||||||
|
(s, _) => OpenInBrowser(((TextBlock)s!).Text); |
||||||
|
|
||||||
|
cancelButton.Click += (_, _) => Close(null); |
||||||
|
viewModel.CloseRequested += path => Close(path); |
||||||
|
Closed += (_, _) => viewModel.CancelAllOperations(); |
||||||
|
|
||||||
|
// Populate the dialog with the feed's default listing so it never opens empty.
|
||||||
|
Opened += (_, _) => viewModel.RefreshCommand.Execute(null); |
||||||
|
Opened += (_, _) => searchBox.Focus(); |
||||||
|
} |
||||||
|
|
||||||
|
void InitializeComponent() => AvaloniaXamlLoader.Load(this); |
||||||
|
|
||||||
|
static void OpenInBrowser(string? url) |
||||||
|
{ |
||||||
|
if (string.IsNullOrWhiteSpace(url) |
||||||
|
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri) |
||||||
|
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue