.NET Decompiler with support for PDB generation, ReadyToRun, Metadata (&more) - cross-platform!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

303 lines
9.3 KiB

// 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 ICSharpCode.ILSpy.NuGetFeeds;
namespace ICSharpCode.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 INuGetIconLoader? iconLoader;
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;
// Cancels the background icon downloads for a result set once it is replaced by a new
// search, so stale icons never land on rows that have been recycled to other packages.
CancellationTokenSource? iconLoadCts;
[ObservableProperty]
string searchText = string.Empty;
[ObservableProperty]
bool includePrerelease;
[ObservableProperty]
string selectedFeedUrl;
[ObservableProperty]
NuGetPackageViewModel? 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<NuGetPackageViewModel> 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,
INuGetIconLoader? iconLoader = null)
{
this.client = client;
this.iconLoader = iconLoader;
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(NuGetPackageViewModel? 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.Info.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();
iconLoadCts?.Cancel();
}
async Task LoadVersionsAsync(NuGetPackageViewModel? 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.Info.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)
{
// A fresh result set replaces the list: stop the previous set's icon
// downloads so they can't complete onto recycled rows.
iconLoadCts?.Cancel();
iconLoadCts = new CancellationTokenSource();
Packages.Clear();
SelectedPackage = null;
}
var iconToken = (iconLoadCts ??= new CancellationTokenSource()).Token;
foreach (var package in results)
{
var item = new NuGetPackageViewModel(package);
Packages.Add(item);
if (iconLoader != null)
_ = item.LoadIconAsync(iconLoader, iconToken);
}
CanLoadMore = results.Count == PageSize;
// Only a feed that actually answered earns a spot in the persisted MRU.
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;
}
}
}
}