diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/GlobalSuppressions.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/GlobalSuppressions.cs index 569d933ca4f..e1793736cd4 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/GlobalSuppressions.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/GlobalSuppressions.cs @@ -21,7 +21,7 @@ [assembly: SuppressMessage("Build", "CA1062:In externally visible method 'object AdditionConverter.Convert(object[] values, Type targetType, object parameter, CultureInfo culture)', validate parameter 'values' is non-null before using it. If appropriate, throw an ArgumentNullException when the argument is null or add a Code Contract precondition asserting non-null argument.", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.AdditionConverter.Convert(System.Object[],System.Type,System.Object,System.Globalization.CultureInfo)~System.Object")] [assembly: SuppressMessage("Build", "CA1822:Member ExplainPackageDeprecationReasons does not access instance data and can be marked as static (Shared in VisualBasic)", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.DetailControlModel.ExplainPackageDeprecationReasons(System.Collections.Generic.IReadOnlyCollection{System.String})~System.String")] [assembly: SuppressMessage("Build", "CA1062:In externally visible method 'DisplayVersion.DisplayVersion(VersionRange range, string additionalInfo, bool isValidVersion = true, bool isCurrentInstalled = false, bool autoReferenced = false, bool isDeprecated = false, string versionFormat = \"N\")', validate parameter 'range' is non-null before using it. If appropriate, throw an ArgumentNullException when the argument is null or add a Code Contract precondition asserting non-null argument.", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.DisplayVersion.#ctor(NuGet.Versioning.VersionRange,System.String,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.String)")] -[assembly: SuppressMessage("Build", "CA1822:Member WaitForInitialResultsAsync does not access instance data and can be marked as static (Shared in VisualBasic)", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.InfiniteScrollList.WaitForInitialResultsAsync(NuGet.PackageManagement.UI.IItemLoader{NuGet.PackageManagement.UI.PackageItemViewModel},System.IProgress{NuGet.PackageManagement.UI.IItemLoaderState},System.Threading.CancellationToken)~System.Threading.Tasks.Task")] +[assembly: SuppressMessage("Build", "CA1822:Member WaitForInitialResultsAsync does not access instance data and can be marked as static (Shared in VisualBasic)", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.ViewModels.InfiniteScrollListViewModel.WaitForInitialResultsAsync(NuGet.PackageManagement.UI.IItemLoader{NuGet.PackageManagement.UI.PackageItemViewModel},System.IProgress{NuGet.PackageManagement.UI.IItemLoaderState},System.Threading.CancellationToken)~System.Threading.Tasks.Task")] [assembly: SuppressMessage("Build", "CA1303:Method 'object NotEqualConverter.Convert(object value, Type targetType, object parameter, CultureInfo culture)' passes a literal string as parameter 'message' of a call to 'ArgumentException.ArgumentException(string message)'. Retrieve the following string(s) from a resource table instead: \"Parameter should not be null and should inherit from IComparable\".", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.NotEqualConverter.Convert(System.Object,System.Type,System.Object,System.Globalization.CultureInfo)~System.Object")] [assembly: SuppressMessage("Build", "CA1062:In externally visible method 'NuGetProjectUpgradeDependencyItem.NuGetProjectUpgradeDependencyItem(PackageIdentity package, PackageWithDependants packageWithDependants)', validate parameter 'packageWithDependants' is non-null before using it. If appropriate, throw an ArgumentNullException when the argument is null or add a Code Contract precondition asserting non-null argument.", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.NuGetProjectUpgradeDependencyItem.#ctor(NuGet.Packaging.Core.PackageIdentity,NuGet.PackageManagement.PackageWithDependants)")] [assembly: SuppressMessage("Build", "CA1822:Member PromoteToTopLevelIfNeeded does not access instance data and can be marked as static (Shared in VisualBasic)", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.NuGetProjectUpgradeWindowModel.PromoteToTopLevelIfNeeded(NuGet.Packaging.PackageArchiveReader,NuGet.PackageManagement.UI.NuGetProjectUpgradeDependencyItem)")] @@ -90,8 +90,8 @@ [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.PackageSolutionDetailControlModel.UpdateInstalledVersionsAsync(System.Threading.CancellationToken)~System.Threading.Tasks.Task")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.UIActionEngine.UpgradeNuGetProjectAsync(NuGet.PackageManagement.UI.INuGetUI,NuGet.VisualStudio.Internal.Contracts.IProjectContextInfo)~System.Threading.Tasks.Task")] [assembly: SuppressMessage("Build", "CA1501:'VulnerabilitiesControl' has an object hierarchy '9' levels deep within the defining module. If possible, eliminate base classes within the hierarchy to decrease its hierarchy level below '6': 'UserControl, ContentControl, Control, FrameworkElement, UIElement, Visual, DependencyObject, DispatcherObject, Object'", Justification = "Default WPF class hierarchy", Scope = "type", Target = "~T:NuGet.PackageManagement.UI.VulnerabilitiesControl")] -[assembly: SuppressMessage("Usage", "VSTHRD010:Invoke single-threaded types on Main thread", Justification = "https://github.com/microsoft/vs-threading/issues/577", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.InfiniteScrollList.WaitForCompletionAsync(NuGet.PackageManagement.UI.IItemLoader{NuGet.PackageManagement.UI.PackageItemViewModel},System.Threading.CancellationToken)~System.Threading.Tasks.Task")] -[assembly: SuppressMessage("Usage", "VSTHRD010:Invoke single-threaded types on Main thread", Justification = "https://github.com/microsoft/vs-threading/issues/577", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.InfiniteScrollList.LoadNextPageAsync(NuGet.PackageManagement.UI.IPackageItemLoader,System.Threading.CancellationToken)~System.Threading.Tasks.Task{System.Collections.Generic.IEnumerable{NuGet.PackageManagement.UI.PackageItemViewModel}}")] +[assembly: SuppressMessage("Usage", "VSTHRD010:Invoke single-threaded types on Main thread", Justification = "https://github.com/microsoft/vs-threading/issues/577", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.ViewModels.InfiniteScrollListViewModel.WaitForCompletionAsync(NuGet.PackageManagement.UI.IItemLoader{NuGet.PackageManagement.UI.PackageItemViewModel},System.Threading.CancellationToken)~System.Threading.Tasks.Task")] +[assembly: SuppressMessage("Usage", "VSTHRD010:Invoke single-threaded types on Main thread", Justification = "https://github.com/microsoft/vs-threading/issues/577", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.ViewModels.InfiniteScrollListViewModel.LoadNextPageAsync(NuGet.PackageManagement.UI.IPackageItemLoader,System.Threading.CancellationToken)~System.Threading.Tasks.Task{System.Collections.Generic.IEnumerable{NuGet.PackageManagement.UI.PackageItemViewModel}}")] [assembly: SuppressMessage("Build", "CA1501:'ButtonHyperlink' has an object hierarchy '9' levels deep within the defining module. If possible, eliminate base classes within the hierarchy to decrease its hierarchy level below '6': 'Hyperlink, Span, Inline, TextElement, FrameworkContentElement, ContentElement, DependencyObject, DispatcherObject, Object' (https://docs.microsoft.com/visualstudio/code-quality/ca1501-avoid-excessive-inheritance)", Justification = "", Scope = "type", Target = "~T:NuGet.PackageManagement.UI.Controls.ButtonHyperlink")] [assembly: SuppressMessage("Build", "CA1501:'ButtonHyperlinkAutomationPeer' has an object hierarchy '8' levels deep within the defining module. If possible, eliminate base classes within the hierarchy to decrease its hierarchy level below '6': 'HyperlinkAutomationPeer, TextElementAutomationPeer, ContentTextAutomationPeer, FrameworkContentElementAutomationPeer, ContentElementAutomationPeer, AutomationPeer, DispatcherObject, Object' (https://docs.microsoft.com/visualstudio/code-quality/ca1501-avoid-excessive-inheritance)", Justification = "", Scope = "type", Target = "~T:NuGet.PackageManagement.UI.Automation.ButtonHyperlinkAutomationPeer")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.UIActionEngine.PerformActionImplAsync(Microsoft.ServiceHub.Framework.IServiceBroker,NuGet.VisualStudio.Internal.Contracts.INuGetProjectManagerService,NuGet.PackageManagement.UI.INuGetUI,NuGet.PackageManagement.UI.UIActionEngine.ResolveActionsAsync,NuGet.PackageManagement.NuGetProjectActionType,NuGet.PackageManagement.UI.UserAction,System.Threading.CancellationToken)~System.Threading.Tasks.Task")] @@ -99,6 +99,8 @@ [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.SolutionUserOptions.ReadUserOptions(Microsoft.VisualStudio.OLE.Interop.IStream,System.String)~System.Int32")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.PackageSourceMappingUtility.FindSourceForPackageInGlobalPackagesFolder(NuGet.Packaging.Core.PackageIdentity,NuGet.Packaging.VersionFolderPathResolver)~System.String")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.SolutionUserOptions.WriteUserOptions(Microsoft.VisualStudio.OLE.Interop.IStream,System.String)~System.Int32")] +[assembly: SuppressMessage("Usage", "VSTHRD010:Invoke single-threaded types on Main thread", Justification = "WPF UserControl constructor runs on UI thread; delegates capture UI controls for later invocation after SwitchToMainThreadAsync", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.InfiniteScrollList.#ctor(System.Lazy{Microsoft.VisualStudio.Threading.JoinableTaskFactory})")] +[assembly: SuppressMessage("Usage", "VSTHRD010:Invoke single-threaded types on Main thread", Justification = "WPF UserControl parameterless constructor chains to internal constructor which is suppressed", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.InfiniteScrollList.#ctor")] [assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.NuGetUI.InvokeOnUIThread(System.Action)")] [assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.SolutionView.UpdateHeaderAutomationProperties(System.Windows.Controls.GridViewColumnHeader)")] [assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.UIActionEngine.CheckPackageManagementFormatAsync(NuGet.VisualStudio.Internal.Contracts.INuGetProjectUpgraderService,NuGet.PackageManagement.UI.INuGetUI,System.Threading.CancellationToken)~System.Threading.Tasks.Task{System.Boolean}")] @@ -108,7 +110,6 @@ [assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.UIActionEngine.ShouldContinueDueToDotnetDeprecationAsync(NuGet.VisualStudio.Internal.Contracts.INuGetProjectManagerService,NuGet.PackageManagement.UI.INuGetUI,System.Threading.CancellationToken)~System.Threading.Tasks.ValueTask{System.Boolean}")] [assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.UIActionEngine.UpgradeNuGetProjectAsync(NuGet.PackageManagement.UI.INuGetUI,NuGet.VisualStudio.Internal.Contracts.IProjectContextInfo)~System.Threading.Tasks.Task")] [assembly: SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.PreviewWindowModel.ToString~System.String")] -[assembly: SuppressMessage("Reliability", "CA2016:Forward the 'CancellationToken' parameter to methods", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.InfiniteScrollList.LoadItemsAsync(NuGet.PackageManagement.UI.IPackageItemLoader,System.String,NuGet.PackageManagement.VisualStudio.INuGetUILogger,System.Threading.Tasks.Task{NuGet.VisualStudio.Internal.Contracts.SearchResultContextInfo},System.Threading.CancellationToken)~System.Threading.Tasks.Task")] [assembly: SuppressMessage("Reliability", "CA2016:Forward the 'CancellationToken' parameter to methods", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.PackageLicenseUtilities.GetEmbeddedLicenseAsync(NuGet.Packaging.Core.PackageIdentity,System.Threading.CancellationToken)~System.Threading.Tasks.Task{System.String}")] [assembly: SuppressMessage("Reliability", "CA2016:Forward the 'CancellationToken' parameter to methods", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.PackageRestoreBar.UIRestorePackagesAsync(System.Threading.CancellationToken)~System.Threading.Tasks.Task{System.Boolean}")] [assembly: SuppressMessage("Reliability", "CA2016:Forward the 'CancellationToken' parameter to methods", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.PackageSolutionDetailControlModel.CreateProjectListsAsync(System.Threading.CancellationToken)~System.Threading.Tasks.Task")] diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/ViewModels/InfiniteScrollListViewModel.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/ViewModels/InfiniteScrollListViewModel.cs new file mode 100644 index 00000000000..70e780215c3 --- /dev/null +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/ViewModels/InfiniteScrollListViewModel.cs @@ -0,0 +1,714 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Threading; +using NuGet.Common; +using NuGet.PackageManagement.VisualStudio; +using NuGet.VisualStudio; +using NuGet.VisualStudio.Internal.Contracts; +using NuGet.VisualStudio.Telemetry; +using Resx = NuGet.PackageManagement.UI; +using Task = System.Threading.Tasks.Task; + +namespace NuGet.PackageManagement.UI.ViewModels +{ + /// + /// ViewModel for , containing all business logic + /// for loading, paging, filtering, selection, and collection management. + /// This class has no references to WPF types and is fully unit-testable. + /// + public class InfiniteScrollListViewModel : ViewModelBase + { + private readonly LoadingStatusIndicator _loadingStatusIndicator = new LoadingStatusIndicator(); + private readonly LoadingStatusIndicator _loadingVulnerabilitiesStatusIndicator = new LoadingStatusIndicator(); + private static readonly TimeSpan PollingDelay = TimeSpan.FromMilliseconds(100); + + private CancellationTokenSource _loadCts; + private IPackageItemLoader _loader; + private INuGetUILogger _logger; + private Task _initialSearchResultTask; + private readonly Lazy _joinableTaskFactory; + + private bool _isUpdateMode; + private bool _isSolution; + private bool _filterByVulnerabilities; + private int _selectedCount; + + // These delegates are set by the codebehind to perform UI-specific operations. + // They are null in unit tests, making all UI operations no-ops. + internal Action StatusBarUpdateLoadingState { get; set; } + internal Action StatusBarSetCancelled { get; set; } + internal Action StatusBarSetError { get; set; } + internal Action StatusBarReset { get; set; } + internal Action LogActivityError { get; set; } + + /// + /// Fires when items in the list have finished loading. + /// + internal event EventHandler LoadItemsCompleted; + + public ReentrantSemaphore ItemsLock { get; private set; } + + public ObservableCollection Items { get; } = new ObservableCollection(); + + internal InfiniteScrollListViewModel(Lazy joinableTaskFactory) + { + if (joinableTaskFactory == null) + { + throw new ArgumentNullException(nameof(joinableTaskFactory)); + } + + _joinableTaskFactory = joinableTaskFactory; + + ItemsLock = ReentrantSemaphore.Create( + initialCount: 1, + joinableTaskContext: _joinableTaskFactory.Value.Context, + mode: ReentrantSemaphore.ReentrancyMode.Stack); + + _loadingStatusIndicator.PropertyChanged += LoadingStatusIndicator_PropertyChanged; + } + + public bool IsUpdateMode + { + get => _isUpdateMode; + set + { + if (SetAndRaisePropertyChanged(ref _isUpdateMode, value)) + { + UpdateSelectionState(); + } + } + } + + public bool IsSolution + { + get => _isSolution; + set => SetAndRaisePropertyChanged(ref _isSolution, value); + } + + /// + /// All loaded items (excluding loading indicators) regardless of filtering. + /// + public IEnumerable PackageItems => Items.OfType().ToArray(); + + /// + /// Count of package items (excluding loading indicators). + /// + public int PackageItemsCount => PackageItems.Count(); + + public int VulnerablePackagesCount => Items.OfType().Count(i => i.IsPackageVulnerable); + + public Guid? OperationId => _loader?.State.OperationId; + + public int SelectedCount => _selectedCount; + + internal LoadingStatusIndicator LoadingStatusIndicator => _loadingStatusIndicator; + + internal IPackageItemLoader Loader => _loader; + + private bool _hasUpdatablePackages; + private bool? _selectionState; + private bool _hasSelectedPackages; + + public bool HasUpdatablePackages + { + get => _hasUpdatablePackages; + private set => SetAndRaisePropertyChanged(ref _hasUpdatablePackages, value); + } + + public bool? SelectionState + { + get => _selectionState; + private set => SetAndRaisePropertyChanged(ref _selectionState, value); + } + + public bool HasSelectedPackages + { + get => _hasSelectedPackages; + private set => SetAndRaisePropertyChanged(ref _hasSelectedPackages, value); + } + + private int _itemsLoaded; + private bool _hasStatusBarContent; + + public int ItemsLoaded + { + get => _itemsLoaded; + private set => SetAndRaisePropertyChanged(ref _itemsLoaded, value); + } + + public bool HasStatusBarContent + { + get => _hasStatusBarContent; + internal set => SetAndRaisePropertyChanged(ref _hasStatusBarContent, value); + } + + /// + /// Load items using the specified loader. This is the main entry point for starting a new search/load. + /// + internal async Task LoadItemsAsync( + IPackageItemLoader loader, + string loadingMessage, + INuGetUILogger logger, + Task searchResultTask, + CancellationToken token) + { + if (loader == null) + { + throw new ArgumentNullException(nameof(loader)); + } + + if (string.IsNullOrEmpty(loadingMessage)) + { + throw new ArgumentException(Strings.Argument_Cannot_Be_Null_Or_Empty, nameof(loadingMessage)); + } + + if (searchResultTask == null) + { + throw new ArgumentNullException(nameof(searchResultTask)); + } + + token.ThrowIfCancellationRequested(); + + _loader = loader; + _logger = logger; + _initialSearchResultTask = searchResultTask; + _loadingStatusIndicator.Reset(loadingMessage); + _loadingVulnerabilitiesStatusIndicator.Reset(string.Format(CultureInfo.CurrentCulture, Resx.Resources.Vulnerabilities_Loading)); + _loadingVulnerabilitiesStatusIndicator.Status = LoadingStatus.Loading; + + HasStatusBarContent = false; + StatusBarReset?.Invoke(loadingMessage, loader.IsMultiSource); + + var selectedPackageItem = SelectedPackageItem; + + await ItemsLock.ExecuteAsync(() => + { + ClearPackageList(); + return Task.CompletedTask; + }, token); + + _selectedCount = 0; + + await LoadItemsAsync(selectedPackageItem, token); + } + + private PackageItemViewModel _selectedPackageItem; + + internal PackageItemViewModel SelectedPackageItem + { + get => _selectedPackageItem; + set => SetAndRaisePropertyChanged(ref _selectedPackageItem, value); + } + + /// + /// Keep the previously selected package after a search. + /// Otherwise, select the first on the search if none was selected before. + /// + internal PackageItemViewModel ResolveSelectedItem(PackageItemViewModel selectedItem) + { + if (selectedItem != null) + { + selectedItem = PackageItems + .FirstOrDefault(item => item.Id.Equals(selectedItem.Id, StringComparison.OrdinalIgnoreCase)); + } + + return selectedItem ?? PackageItems.FirstOrDefault(); + } + + private async Task LoadItemsAsync(PackageItemViewModel selectedPackageItem, CancellationToken token) + { + var loadCts = CancellationTokenSource.CreateLinkedTokenSource(token); + Interlocked.Exchange(ref _loadCts, loadCts)?.Cancel(); + + await RepopulatePackageListAsync(selectedPackageItem, _loader, loadCts); + } + + private async Task RepopulatePackageListAsync(PackageItemViewModel selectedPackageItem, IPackageItemLoader currentLoader, CancellationTokenSource loadCts) + { + await TaskScheduler.Default; + + var addedLoadingIndicator = false; + + try + { + if (!Items.Contains(_loadingStatusIndicator)) + { + Items.Add(_loadingStatusIndicator); + addedLoadingIndicator = true; + } + + if (!Items.Contains(_loadingVulnerabilitiesStatusIndicator)) + { + Items.Add(_loadingVulnerabilitiesStatusIndicator); + } + + await LoadItemsCoreAsync(currentLoader, loadCts.Token); + + await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); + + if (selectedPackageItem != null) + { + SelectedPackageItem = ResolveSelectedItem(selectedPackageItem); + } + } + catch (OperationCanceledException) when (!loadCts.IsCancellationRequested) + { + loadCts.Cancel(); + loadCts.Dispose(); + currentLoader.Reset(); + + await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); + + _logger.Log(new LogMessage(LogLevel.Error, Resx.Resources.Text_UserCanceled)); + + _loadingStatusIndicator.SetError(Resx.Resources.Text_UserCanceled); + + StatusBarSetCancelled?.Invoke(); + HasStatusBarContent = true; + } + catch (Exception ex) when (!loadCts.IsCancellationRequested) + { + loadCts.Cancel(); + loadCts.Dispose(); + currentLoader.Reset(); + + LogActivityError?.Invoke(ex.ToString()); + + await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); + + var errorMessage = ExceptionUtilities.DisplayMessage(ex); + _logger.Log(new LogMessage(LogLevel.Error, errorMessage)); + + _loadingStatusIndicator.SetError(errorMessage); + + StatusBarSetError?.Invoke(); + HasStatusBarContent = true; + } + finally + { + if (VulnerablePackagesCount == 0) + { + _loadingVulnerabilitiesStatusIndicator.Status = LoadingStatus.NoItemsFound; + } + else + { + Items.Remove(_loadingVulnerabilitiesStatusIndicator); + } + + if (_loadingStatusIndicator.Status != LoadingStatus.NoItemsFound + && _loadingStatusIndicator.Status != LoadingStatus.ErrorOccurred) + { + var emptyListCount = addedLoadingIndicator ? 1 : 0; + if (Items.Count == emptyListCount) + { + _loadingStatusIndicator.Status = LoadingStatus.NoItemsFound; + } + else + { + Items.Remove(_loadingStatusIndicator); + } + } + } + + UpdateSelectionState(); + + LoadItemsCompleted?.Invoke(this, EventArgs.Empty); + } + + private async Task LoadItemsCoreAsync(IPackageItemLoader currentLoader, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + var loadedItems = await LoadNextPageAsync(currentLoader, token); + token.ThrowIfCancellationRequested(); + + if (currentLoader == _loader) + { + UpdatePackageList(loadedItems, refresh: false); + } + + token.ThrowIfCancellationRequested(); + + await _joinableTaskFactory.Value.RunAsync(async () => + { + await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); + ItemsLoaded = currentLoader.State.ItemsCount; + }); + + token.ThrowIfCancellationRequested(); + + await WaitForCompletionAsync(currentLoader, token); + + token.ThrowIfCancellationRequested(); + + if (currentLoader == _loader + && !loadedItems.Any() + && currentLoader.State.LoadingStatus == LoadingStatus.Ready) + { + UpdatePackageList(currentLoader.GetCurrent(), refresh: false); + } + + token.ThrowIfCancellationRequested(); + } + + private async Task> LoadNextPageAsync(IPackageItemLoader currentLoader, CancellationToken token) + { + var progress = new Progress( + s => HandleItemLoaderStateChange(currentLoader, s)); + + if (_initialSearchResultTask != null) + { + token.ThrowIfCancellationRequested(); + + await currentLoader.UpdateStateAndReportAsync(new SearchResultContextInfo(), progress, token); + + var results = await _initialSearchResultTask; + + token.ThrowIfCancellationRequested(); + + await currentLoader.UpdateStateAndReportAsync(results, progress, token); + + _initialSearchResultTask = null; + } + else + { + await currentLoader.LoadNextAsync(progress, token); + } + + await WaitForInitialResultsAsync(currentLoader, progress, token); + + return currentLoader.GetCurrent(); + } + + private async Task WaitForCompletionAsync(IItemLoader currentLoader, CancellationToken token) + { + var progress = new Progress( + s => HandleItemLoaderStateChange(currentLoader, s)); + + while (currentLoader.State.LoadingStatus == LoadingStatus.Loading) + { + token.ThrowIfCancellationRequested(); + await Task.Delay(PollingDelay, token); + await currentLoader.UpdateStateAsync(progress, token); + } + } + + private async Task WaitForInitialResultsAsync( + IItemLoader currentLoader, + IProgress progress, + CancellationToken token) + { + while (currentLoader.State.LoadingStatus == LoadingStatus.Loading && + currentLoader.State.ItemsCount == 0) + { + token.ThrowIfCancellationRequested(); + await Task.Delay(PollingDelay, token); + await currentLoader.UpdateStateAsync(progress, token); + } + } + + /// + /// Called when the scroll position indicates more items should be loaded. + /// + internal async Task LoadMoreItemsAsync() + { + if (_loader?.State.LoadingStatus == LoadingStatus.Ready) + { + await LoadItemsAsync(selectedPackageItem: null, token: CancellationToken.None); + } + } + + private void HandleItemLoaderStateChange(IItemLoader loader, IItemLoaderState state) + { + _joinableTaskFactory.Value.RunAsync(async () => + { + if (loader == _loader) + { + await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); + + StatusBarUpdateLoadingState?.Invoke(state); + + var shouldShow = ShouldShowStatusBar(state); + + if (shouldShow) + { + HasStatusBarContent = true; + } + + _loadingStatusIndicator.Status = state.LoadingStatus; + + if (!Items.Contains(_loadingStatusIndicator)) + { + await ItemsLock.ExecuteAsync(() => + { + Items.Add(_loadingStatusIndicator); + return Task.CompletedTask; + }); + } + } + }).PostOnFailure(nameof(InfiniteScrollListViewModel), nameof(HandleItemLoaderStateChange)); + } + + private void LoadingStatusIndicator_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(LoadingStatusIndicator.Status)) + { + RaisePropertyChanged(nameof(LoadingStatusLocalizedText)); + } + } + + /// + /// The localized status text for accessibility/narrator use. + /// + public string LoadingStatusLocalizedText => _loadingStatusIndicator.LocalizedStatus; + + /// + /// Appends packages to the internal Items list. + /// + internal void UpdatePackageList(IEnumerable packages, bool refresh) + { + _joinableTaskFactory.Value.Run(async () => + { + await ItemsLock.ExecuteAsync(() => + { + bool removed = Items.Remove(_loadingStatusIndicator); + + if (refresh) + { + ClearPackageList(); + } + + foreach (var package in packages) + { + package.PropertyChanged += Package_PropertyChanged; + Items.Add(package); + _selectedCount = package.IsSelected ? _selectedCount + 1 : _selectedCount; + } + + if (removed) + { + Items.Add(_loadingStatusIndicator); + } + + return Task.CompletedTask; + }); + }); + } + + /// + /// Clears the Items list and removes event handlers for each element. + /// + internal void ClearPackageList() + { + foreach (var package in PackageItems) + { + package.PropertyChanged -= Package_PropertyChanged; + package.Dispose(); + } + + Items.Clear(); + ItemsLoaded = 0; + } + + public async Task UpdatePackageStatusAsync(PackageCollectionItem[] installedPackages, CancellationToken cancellationToken, bool clearCache = false) + { + foreach (var package in PackageItems) + { + if (package.PackageLevel == PackageLevel.TopLevel) + { + await package.UpdatePackageStatusAsync(installedPackages, cancellationToken, clearCache); + } + else + { + await package.UpdateTransitivePackageStatusAsync(cancellationToken); + } + } + } + + /// + /// Handles "Show more results" click by refreshing the package list with all current loader items. + /// + internal void ShowMoreResults() + { + var packageItems = _loader?.GetCurrent() ?? Enumerable.Empty(); + UpdatePackageList(packageItems, refresh: true); + ItemsLoaded = _loader?.State.ItemsCount ?? 0; + + HasStatusBarContent = ShouldShowStatusBar(_loader?.State); + } + + internal bool ShouldShowStatusBar(IItemLoaderState state) + { + if (state == null) + { + return false; + } + + if (state.LoadingStatus == LoadingStatus.Cancelled + || state.LoadingStatus == LoadingStatus.ErrorOccurred) + { + return true; + } + + if (_loader?.IsMultiSource == true) + { + var hasMore = ItemsLoaded != 0 && state.ItemsCount > ItemsLoaded; + if (hasMore) + { + return true; + } + + if (state.LoadingStatus == LoadingStatus.Loading && state.ItemsCount > 0) + { + return true; + } + } + + return false; + } + + private void Package_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + var package = sender as PackageItemViewModel; + if (e.PropertyName == nameof(package.IsSelected)) + { + if (package.IsSelected) + { + _selectedCount++; + } + else + { + _selectedCount--; + } + + UpdateSelectionState(); + } + } + + /// + /// Evaluates the current selection and updates the selection state properties. + /// + internal void UpdateSelectionState() + { + if (!IsUpdateMode) + { + HasUpdatablePackages = false; + SelectionState = false; + HasSelectedPackages = false; + return; + } + + int packageCount = PackageItemsCount; + + HasUpdatablePackages = packageCount > 0; + + if (_selectedCount == 0) + { + SelectionState = false; + HasSelectedPackages = false; + } + else if (_selectedCount < packageCount) + { + SelectionState = null; // indeterminate + HasSelectedPackages = true; + } + else + { + SelectionState = true; + HasSelectedPackages = true; + } + } + + public void SelectAll() + { + foreach (var package in PackageItems) + { + package.IsSelected = true; + } + } + + public void DeselectAll() + { + foreach (var package in PackageItems) + { + package.IsSelected = false; + } + } + + public PackageItemViewModel[] GetSelectedPackages() + { + return PackageItems.Where(p => p.IsSelected).ToArray(); + } + + /// + /// Combined filter predicate for use with CollectionViewSource.Filter. + /// + public bool FilterItem(object item) + { + return FilterLoadingIndicator(item) + && FilterVulnerabilitiesIndicator(item) + && FilterVulnerablePackage(item); + } + + internal bool FilterVulnerabilitiesIndicator(object item) + { + if (item.Equals(_loadingVulnerabilitiesStatusIndicator)) + { + return _filterByVulnerabilities && !(_loadingVulnerabilitiesStatusIndicator.Status == LoadingStatus.NoItemsFound && VulnerablePackagesCount > 0); + } + + return true; + } + + internal bool FilterLoadingIndicator(object item) + { + if (item.Equals(_loadingStatusIndicator)) + { + return !_filterByVulnerabilities; + } + + return true; + } + + internal bool FilterVulnerablePackage(object item) + { + if (_filterByVulnerabilities && item is PackageItemViewModel vm && !vm.IsPackageVulnerable) + { + return false; + } + + return true; + } + + internal void SetVulnerabilitiesFiltering(bool enabled) + { + _filterByVulnerabilities = enabled; + } + + /// + /// Returns true if any items have transitive package level, indicating grouping should be applied. + /// + public bool HasTransitiveItems() + { + return Items + .OfType() + .Any(p => p.PackageLevel == PackageLevel.Transitive); + } + + public void ResetLoadingStatusIndicator() + { + _loadingStatusIndicator.Reset(string.Empty); + } + } +} diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/InfiniteScrollList.xaml b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/InfiniteScrollList.xaml index 93fc65469ab..d950003c022 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/InfiniteScrollList.xaml +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/InfiniteScrollList.xaml @@ -408,14 +408,16 @@ - + + Visibility="{Binding HasUpdatablePackages, Converter={StaticResource BooleanToVisibilityConverter}}"> @@ -427,6 +429,7 @@ Grid.Column="0" Margin="4, 8" VerticalAlignment="Center" + IsChecked="{Binding SelectionState, Mode=OneWay}" Checked="SelectAllPackagesCheckBox_Checked" Unchecked="SelectAllPackagesCheckBox_Unchecked" Foreground="{DynamicResource {x:Static nuget:Brushes.UIText}}" @@ -438,6 +441,7 @@ MinHeight="24" Margin="24,8" VerticalAlignment="Center" + IsEnabled="{Binding HasSelectedPackages, Mode=OneWay}" Click="_updateButton_Click" Content="{x:Static nuget:Resources.Button_Update}" /> @@ -450,14 +454,16 @@ x:Name="_loadingStatusBar" ShowMoreResultsClick="_loadingStatusBar_ShowMoreResultsClick" DismissClick="_loadingStatusBar_DismissClick" + ItemsLoaded="{Binding ViewModel.ItemsLoaded, ElementName=_infiniteScrollList}" Style="{StaticResource FadeAnimationStyle}" - Visibility="Hidden"/> + Visibility="{Binding ViewModel.HasStatusBarContent, ElementName=_infiniteScrollList, Converter={StaticResource BooleanToHiddenVisibilityConverter}}"/> diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/InfiniteScrollList.xaml.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/InfiniteScrollList.xaml.cs index f5be3b78d99..8eaaa411ae2 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/InfiniteScrollList.xaml.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/InfiniteScrollList.xaml.cs @@ -4,40 +4,33 @@ #nullable disable using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; using System.ComponentModel; -using System.Globalization; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Input; using System.Windows.Media; -using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Threading; -using NuGet.Common; -using NuGet.PackageManagement.VisualStudio; +using NuGet.PackageManagement.UI.ViewModels; using NuGet.VisualStudio; using NuGet.VisualStudio.Internal.Contracts; -using NuGet.VisualStudio.Telemetry; using Mvs = Microsoft.VisualStudio.Shell; -using Resx = NuGet.PackageManagement.UI; -using Task = System.Threading.Tasks.Task; namespace NuGet.PackageManagement.UI { /// - /// Interaction logic for InfiniteScrollList.xaml + /// Interaction logic for InfiniteScrollList.xaml. + /// This codebehind handles only WPF-specific concerns; all business logic + /// is in . /// public partial class InfiniteScrollList : UserControl { - private readonly LoadingStatusIndicator _loadingStatusIndicator = new LoadingStatusIndicator(); - private readonly LoadingStatusIndicator _loadingVulnerabilitiesStatusIndicator = new LoadingStatusIndicator(); private ScrollViewer _scrollViewer; - private static TimeSpan PollingDelay = TimeSpan.FromMilliseconds(100); + private readonly Lazy _joinableTaskFactory; + private const string LogEntrySource = "NuGet Package Manager"; + + public InfiniteScrollListViewModel ViewModel { get; } public event SelectionChangedEventHandler SelectionChanged; public event RoutedEventHandler GroupExpansionChanged; @@ -45,26 +38,6 @@ public partial class InfiniteScrollList : UserControl public delegate void UpdateButtonClickEventHandler(PackageItemViewModel[] selectedPackages); public event UpdateButtonClickEventHandler UpdateButtonClicked; - /// - /// Fires when the items in the list have finished loading. - /// It is triggered at , just before it is finished - /// - internal event EventHandler LoadItemsCompleted; - - private CancellationTokenSource _loadCts; - private IPackageItemLoader _loader; - private INuGetUILogger _logger; - private Task _initialSearchResultTask; - private readonly Lazy _joinableTaskFactory; - private bool _checkBoxesEnabled; - - private const string LogEntrySource = "NuGet Package Manager"; - - private bool _filterByVulnerabilities = false; - - // The count of packages that are selected - private int _selectedCount; - public InfiniteScrollList() : this(new Lazy(() => NuGetUIThreadHelper.JoinableTaskFactory)) { @@ -79,126 +52,59 @@ internal InfiniteScrollList(Lazy joinableTaskFactory) _joinableTaskFactory = joinableTaskFactory; + ViewModel = new InfiniteScrollListViewModel(joinableTaskFactory); + InitializeComponent(); - _list.ItemsLock = ReentrantSemaphore.Create( - initialCount: 1, - joinableTaskContext: _joinableTaskFactory.Value.Context, - mode: ReentrantSemaphore.ReentrancyMode.Stack); + // Share the ViewModel's semaphore with the ListBox for synchronized collection access + _list.ItemsLock = ViewModel.ItemsLock; - BindingOperations.EnableCollectionSynchronization(Items, _list.ItemsLock); + BindingOperations.EnableCollectionSynchronization(ViewModel.Items, _list.ItemsLock); - ItemsView = new CollectionViewSource() { Source = Items }.View; + ItemsView = new CollectionViewSource() { Source = ViewModel.Items }.View; ICollectionViewLiveShaping itemsView = (ICollectionViewLiveShaping)ItemsView; itemsView.IsLiveFiltering = true; itemsView.IsLiveGrouping = true; itemsView.LiveFilteringProperties.Add(nameof(PackageItemViewModel.IsPackageVulnerable)); itemsView.LiveGroupingProperties.Add(nameof(PackageItemViewModel.PackageLevel)); - ItemsView.Filter = item => - { - return FilterLoadingIndicator(item) - && FilterVulnerabilitiesIndicator(item) - && FilterVulnerablePackage(item); - }; + ItemsView.Filter = item => ViewModel.FilterItem(item); - DataContext = itemsView; - CheckBoxesEnabled = false; + // Set the ListBox's DataContext to the filtered/grouped view so ItemsSource="{Binding}" works. + // This must be done in codebehind (not XAML) because ItemsView is created after InitializeComponent. + _list.DataContext = ItemsView; - _loadingStatusIndicator.PropertyChanged += LoadingStatusIndicator_PropertyChanged; - } + ViewModel.IsUpdateMode = false; - private bool FilterVulnerabilitiesIndicator(object item) - { - if (item.Equals(_loadingVulnerabilitiesStatusIndicator)) - { - return _filterByVulnerabilities && !(_loadingVulnerabilitiesStatusIndicator.Status == LoadingStatus.NoItemsFound && VulnerablePackagesCount > 0); - } - - return true; - } - - private bool FilterLoadingIndicator(object item) - { - if (item.Equals(_loadingStatusIndicator)) - { - return !_filterByVulnerabilities; - } - - return true; - } - - private bool FilterVulnerablePackage(object item) - { - if (_filterByVulnerabilities && item is PackageItemViewModel vm && !vm.IsPackageVulnerable) - { - return false; - } - - return true; - } - - private void LoadingStatusIndicator_PropertyChanged(object sender, PropertyChangedEventArgs e) - { - _joinableTaskFactory.Value.Run(async delegate - { - await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); - if (e.PropertyName == nameof(LoadingStatusIndicator.Status) - && _ltbLoading.Text != _loadingStatusIndicator.LocalizedStatus) - { - _ltbLoading.Text = _loadingStatusIndicator.LocalizedStatus; - } - }); - } + // Wire UI callback delegates for operations that cannot be expressed in XAML. + // These must be set before any LoadItemsAsync call (PackageManagerControl calls it during init). + ViewModel.StatusBarUpdateLoadingState = state => _loadingStatusBar.UpdateLoadingState(state); + ViewModel.StatusBarSetCancelled = () => _loadingStatusBar.SetCancelled(); + ViewModel.StatusBarSetError = () => _loadingStatusBar.SetError(); + ViewModel.StatusBarReset = (msg, isMulti) => _loadingStatusBar.Reset(msg, isMulti); + ViewModel.LogActivityError = msg => Mvs.ActivityLog.LogError(LogEntrySource, msg); - public bool CheckBoxesEnabled - { - get => _checkBoxesEnabled; - set + // Sync IsUpdateMode to the ListBox's IsItemSelectionEnabled (not a DependencyProperty, can't bind) + ViewModel.PropertyChanged += (s, e) => { - if (_checkBoxesEnabled != value) + if (e.PropertyName == nameof(InfiniteScrollListViewModel.IsUpdateMode)) { - _checkBoxesEnabled = value; - _list.IsItemSelectionEnabled = value; + _list.IsItemSelectionEnabled = ViewModel.IsUpdateMode; } - } + }; } - public bool IsSolution { get; set; } - - public ObservableCollection Items { get; } = new ObservableCollection(); - public ICollectionView ItemsView { get; private set; } - /// - /// Count of Items (excluding Loading indicator) that are currently shown after applying any UI filtering. - /// - private int FilteredItemsCount - { - get - { - return PackageItems.Count(); - } - } - - /// - /// All loaded Items (excluding Loading indicator) regardless of filtering. - /// - public IEnumerable PackageItems => Items.OfType().ToArray(); - - private int VulnerablePackagesCount => Items.OfType().Count(i => i.IsPackageVulnerable); - - public PackageItemViewModel SelectedPackageItem => _list.SelectedItem as PackageItemViewModel; - public int SelectedIndex => _list.SelectedIndex; - public Guid? OperationId => _loader?.State.OperationId; - public int TopLevelPackageCount { get { - var group = ItemsView.Groups.FirstOrDefault(g => (g as CollectionViewGroup).Name.ToString().Equals(PackageLevel.TopLevel.ToString(), StringComparison.OrdinalIgnoreCase)); - return group is not null ? (group as CollectionViewGroup).ItemCount : 0; + var group = ItemsView.Groups?.FirstOrDefault( + g => (g as CollectionViewGroup)?.Name?.ToString() + .Equals(PackageLevel.TopLevel.ToString(), StringComparison.OrdinalIgnoreCase) == true); + return group is CollectionViewGroup cvg ? cvg.ItemCount : 0; } } @@ -206,485 +112,36 @@ public int TransitivePackageCount { get { - var group = ItemsView.Groups.FirstOrDefault(g => (g as CollectionViewGroup).Name.ToString().Equals(PackageLevel.Transitive.ToString(), StringComparison.OrdinalIgnoreCase)); - return group is not null ? (group as CollectionViewGroup).ItemCount : 0; + var group = ItemsView.Groups?.FirstOrDefault( + g => (g as CollectionViewGroup)?.Name?.ToString() + .Equals(PackageLevel.Transitive.ToString(), StringComparison.OrdinalIgnoreCase) == true); + return group is CollectionViewGroup cvg ? cvg.ItemCount : 0; } } - // Load items using the specified loader - internal async Task LoadItemsAsync( - IPackageItemLoader loader, - string loadingMessage, - INuGetUILogger logger, - Task searchResultTask, - CancellationToken token) - { - if (loader == null) - { - throw new ArgumentNullException(nameof(loader)); - } - - if (string.IsNullOrEmpty(loadingMessage)) - { - throw new ArgumentException(Strings.Argument_Cannot_Be_Null_Or_Empty, nameof(loadingMessage)); - } - - if (searchResultTask == null) - { - throw new ArgumentNullException(nameof(searchResultTask)); - } - - token.ThrowIfCancellationRequested(); - - _loader = loader; - _logger = logger; - _initialSearchResultTask = searchResultTask; - _loadingStatusIndicator.Reset(loadingMessage); - _loadingVulnerabilitiesStatusIndicator.Reset(string.Format(CultureInfo.CurrentCulture, Resx.Resources.Vulnerabilities_Loading)); - _loadingVulnerabilitiesStatusIndicator.Status = LoadingStatus.Loading; - _loadingStatusBar.Visibility = Visibility.Hidden; - _loadingStatusBar.Reset(loadingMessage, loader.IsMultiSource); - - var selectedPackageItem = SelectedPackageItem; - - await _list.ItemsLock.ExecuteAsync(() => - { - ClearPackageList(); - return Task.CompletedTask; - }); - - _selectedCount = 0; - - // triggers the package list loader - await LoadItemsAsync(selectedPackageItem, token); - } - - /// - /// Keep the previously selected package after a search. - /// Otherwise, select the first on the search if none was selected before. - /// - /// Previously selected item - internal void UpdateSelectedItem(PackageItemViewModel selectedItem) - { - if (selectedItem != null) - { - // select the the previously selected item if it still exists. - selectedItem = PackageItems - .FirstOrDefault(item => item.Id.Equals(selectedItem.Id, StringComparison.OrdinalIgnoreCase)); - } - - // select the first item if none was selected before - _list.SelectedItem = selectedItem ?? PackageItems.FirstOrDefault(); - } - - private async Task LoadItemsAsync(PackageItemViewModel selectedPackageItem, CancellationToken token) - { - // If there is another async loading process - cancel it. - var loadCts = CancellationTokenSource.CreateLinkedTokenSource(token); - Interlocked.Exchange(ref _loadCts, loadCts)?.Cancel(); - - await RepopulatePackageListAsync(selectedPackageItem, _loader, loadCts); - } - - private async Task RepopulatePackageListAsync(PackageItemViewModel selectedPackageItem, IPackageItemLoader currentLoader, CancellationTokenSource loadCts) - { - await TaskScheduler.Default; - - var addedLoadingIndicator = false; - - try - { - // add Loading... indicator if not present - if (!Items.Contains(_loadingStatusIndicator)) - { - Items.Add(_loadingStatusIndicator); - addedLoadingIndicator = true; - } - - if (!Items.Contains(_loadingVulnerabilitiesStatusIndicator)) - { - Items.Add(_loadingVulnerabilitiesStatusIndicator); - } - - await LoadItemsCoreAsync(currentLoader, loadCts.Token); - - await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); - - if (selectedPackageItem != null) - { - UpdateSelectedItem(selectedPackageItem); - } - } - catch (OperationCanceledException) when (!loadCts.IsCancellationRequested) - { - loadCts.Cancel(); - loadCts.Dispose(); - currentLoader.Reset(); - - await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); - - // The user cancelled the login, but treat as a load error in UI - // So the retry button and message is displayed - // Do not log to the activity log, since it is not a NuGet error - _logger.Log(new LogMessage(LogLevel.Error, Resx.Resources.Text_UserCanceled)); - - _loadingStatusIndicator.SetError(Resx.Resources.Text_UserCanceled); - - _loadingStatusBar.SetCancelled(); - _loadingStatusBar.Visibility = Visibility.Visible; - } - catch (Exception ex) when (!loadCts.IsCancellationRequested) - { - loadCts.Cancel(); - loadCts.Dispose(); - currentLoader.Reset(); - - // Write stack to activity log - Mvs.ActivityLog.LogError(LogEntrySource, ex.ToString()); - - await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); - - var errorMessage = ExceptionUtilities.DisplayMessage(ex); - _logger.Log(new LogMessage(LogLevel.Error, errorMessage)); - - _loadingStatusIndicator.SetError(errorMessage); - - _loadingStatusBar.SetError(); - _loadingStatusBar.Visibility = Visibility.Visible; - } - finally - { - if (VulnerablePackagesCount == 0) - { - _loadingVulnerabilitiesStatusIndicator.Status = LoadingStatus.NoItemsFound; - } - else - { - Items.Remove(_loadingVulnerabilitiesStatusIndicator); - } - - if (_loadingStatusIndicator.Status != LoadingStatus.NoItemsFound - && _loadingStatusIndicator.Status != LoadingStatus.ErrorOccurred) - { - // Ideally, after a search, it should report its status, and - // do not keep the LoadingStatus.Loading forever. - // This is a workaround. - var emptyListCount = addedLoadingIndicator ? 1 : 0; - if (Items.Count == emptyListCount) - { - _loadingStatusIndicator.Status = LoadingStatus.NoItemsFound; - } - else - { - Items.Remove(_loadingStatusIndicator); - } - } - } - - UpdateCheckBoxStatus(); - - LoadItemsCompleted?.Invoke(this, EventArgs.Empty); - } - - private async Task LoadItemsCoreAsync(IPackageItemLoader currentLoader, CancellationToken token) - { - token.ThrowIfCancellationRequested(); - - var loadedItems = await LoadNextPageAsync(currentLoader, token); - token.ThrowIfCancellationRequested(); - - // multiple loads may occur at the same time as a result of multiple instances, - // makes sure we update using the relevant one. - if (currentLoader == _loader) - { - UpdatePackageList(loadedItems, refresh: false); - } - - token.ThrowIfCancellationRequested(); - - await _joinableTaskFactory.Value.RunAsync(async () => - { - await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); - - _loadingStatusBar.ItemsLoaded = currentLoader.State.ItemsCount; - }); - - token.ThrowIfCancellationRequested(); - - // keep waiting till completion - await WaitForCompletionAsync(currentLoader, token); - - token.ThrowIfCancellationRequested(); - - if (currentLoader == _loader - && !loadedItems.Any() - && currentLoader.State.LoadingStatus == LoadingStatus.Ready) - { - UpdatePackageList(currentLoader.GetCurrent(), refresh: false); - } - - token.ThrowIfCancellationRequested(); - } - - private async Task> LoadNextPageAsync(IPackageItemLoader currentLoader, CancellationToken token) - { - var progress = new Progress( - s => HandleItemLoaderStateChange(currentLoader, s)); - - // if searchResultTask is in progress then just wait for it to complete - // without creating new load task - if (_initialSearchResultTask != null) - { - token.ThrowIfCancellationRequested(); - - // update initial progress - await currentLoader.UpdateStateAndReportAsync(new SearchResultContextInfo(), progress, token); - - var results = await _initialSearchResultTask; - - token.ThrowIfCancellationRequested(); - - // update state and progress - await currentLoader.UpdateStateAndReportAsync(results, progress, token); - - _initialSearchResultTask = null; - } - else - { - // trigger loading - await currentLoader.LoadNextAsync(progress, token); - } - - await WaitForInitialResultsAsync(currentLoader, progress, token); - - return currentLoader.GetCurrent(); - } - - private async Task WaitForCompletionAsync(IItemLoader currentLoader, CancellationToken token) - { - var progress = new Progress( - s => HandleItemLoaderStateChange(currentLoader, s)); - - // run to completion - while (currentLoader.State.LoadingStatus == LoadingStatus.Loading) - { - token.ThrowIfCancellationRequested(); - await Task.Delay(PollingDelay, token); - await currentLoader.UpdateStateAsync(progress, token); - } - } - - private async Task WaitForInitialResultsAsync( - IItemLoader currentLoader, - IProgress progress, - CancellationToken token) - { - while (currentLoader.State.LoadingStatus == LoadingStatus.Loading && - currentLoader.State.ItemsCount == 0) - { - token.ThrowIfCancellationRequested(); - await Task.Delay(PollingDelay, token); - await currentLoader.UpdateStateAsync(progress, token); - } - } - - /// - /// Shows the Loading status bar, if necessary. Also, it inserts the Loading... indicator, if necesary - /// - /// Current loader - /// Progress reported by the Progress callback - private void HandleItemLoaderStateChange(IItemLoader loader, IItemLoaderState state) - { - NuGetUIThreadHelper.JoinableTaskFactory.RunAsync(async () => - { - if (loader == _loader) - { - await _joinableTaskFactory.Value.SwitchToMainThreadAsync(); - - _loadingStatusBar.UpdateLoadingState(state); - - // decide when to show status bar - var desiredVisibility = EvaluateStatusBarVisibility(loader, state); - - if (_loadingStatusBar.Visibility != Visibility.Visible - && desiredVisibility == Visibility.Visible) - { - _loadingStatusBar.Visibility = desiredVisibility; - } - - _loadingStatusIndicator.Status = state.LoadingStatus; - - if (!Items.Contains(_loadingStatusIndicator)) - { - await _list.ItemsLock.ExecuteAsync(() => - { - Items.Add(_loadingStatusIndicator); - return Task.CompletedTask; - }); - } - } - }).PostOnFailure(nameof(InfiniteScrollList), nameof(HandleItemLoaderStateChange)); - } - - private Visibility EvaluateStatusBarVisibility(IItemLoader loader, IItemLoaderState state) - { - var statusBarVisibility = Visibility.Hidden; - - if (state.LoadingStatus == LoadingStatus.Cancelled - || state.LoadingStatus == LoadingStatus.ErrorOccurred) - { - statusBarVisibility = Visibility.Visible; - } - - if (loader.IsMultiSource) - { - var hasMore = _loadingStatusBar.ItemsLoaded != 0 && state.ItemsCount > _loadingStatusBar.ItemsLoaded; - if (hasMore) - { - statusBarVisibility = Visibility.Visible; - } - - if (state.LoadingStatus == LoadingStatus.Loading && state.ItemsCount > 0) - { - statusBarVisibility = Visibility.Visible; - } - } - - return statusBarVisibility; - } - - /// - /// Appends packages to the internal list - /// - /// Packages collection to add - /// Clears list if set to - private void UpdatePackageList(IEnumerable packages, bool refresh) - { - _joinableTaskFactory.Value.Run(async () => - { - // Synchronize updating Items list - await _list.ItemsLock.ExecuteAsync(() => - { - // remove the loading status indicator if it's in the list - bool removed = Items.Remove(_loadingStatusIndicator); - - if (refresh) - { - ClearPackageList(); - } - - // add newly loaded items - foreach (var package in packages) - { - package.PropertyChanged += Package_PropertyChanged; - Items.Add(package); - _selectedCount = package.IsSelected ? _selectedCount + 1 : _selectedCount; - } - - if (removed) - { - Items.Add(_loadingStatusIndicator); - } - - return Task.CompletedTask; - }); - }); - } - - /// - /// Clear Items list and removes the event handlers for each element - /// - private void ClearPackageList() - { - foreach (var package in PackageItems) - { - package.PropertyChanged -= Package_PropertyChanged; - package.Dispose(); - } - - Items.Clear(); - _loadingStatusBar.ItemsLoaded = 0; - } - - public async Task UpdatePackageStatusAsync(PackageCollectionItem[] installedPackages, CancellationToken cancellationToken, bool clearCache = false) + internal void ClearPackageLevelGrouping() { - // in this case, we only need to update PackageStatus of - // existing items in the package list - foreach (var package in PackageItems) - { - if (package.PackageLevel == PackageLevel.TopLevel) - { - await package.UpdatePackageStatusAsync(installedPackages, cancellationToken, clearCache); - } - else - { - await package.UpdateTransitivePackageStatusAsync(cancellationToken); - } - } + ItemsView.GroupDescriptions.Clear(); } - private void Package_PropertyChanged(object sender, PropertyChangedEventArgs e) + internal void AddVulnerabilitiesFiltering() { - var package = sender as PackageItemViewModel; - if (e.PropertyName == nameof(package.IsSelected)) - { - if (package.IsSelected) - { - _selectedCount++; - } - else - { - _selectedCount--; - } - - UpdateCheckBoxStatus(); - } + ViewModel.SetVulnerabilitiesFiltering(true); + ItemsView.Refresh(); } - // Update the status of the _selectAllPackages check box and the Update button. - private void UpdateCheckBoxStatus() + internal void RemoveVulnerabilitiesFiltering() { - // The current tab is not "Updates". - if (!CheckBoxesEnabled) - { - _updateButtonContainer.Visibility = Visibility.Collapsed; - return; - } - - //Are any packages shown with the current filter? - int packageCount = FilteredItemsCount; - - _updateButtonContainer.Visibility = - packageCount > 0 ? - Visibility.Visible : - Visibility.Collapsed; - - if (_selectedCount == 0) - { - _selectAllPackages.IsChecked = false; - _updateButton.IsEnabled = false; - } - else if (_selectedCount < packageCount) - { - _selectAllPackages.IsChecked = null; - _updateButton.IsEnabled = true; - } - else - { - _selectAllPackages.IsChecked = true; - _updateButton.IsEnabled = true; - } + ViewModel.SetVulnerabilitiesFiltering(false); + ItemsView.Refresh(); } - public PackageItemViewModel SelectedItem + internal void AddPackageLevelGrouping() { - get - { - return _list.SelectedItem as PackageItemViewModel; - } - internal set + ItemsView.Refresh(); + if (ViewModel.HasTransitiveItems()) { - _list.SelectedItem = value; + ItemsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PackageItemViewModel.PackageLevel))); } } @@ -705,10 +162,7 @@ private void List_SelectionChanged(object sender, SelectionChangedEventArgs e) } else { - if (SelectionChanged != null) - { - SelectionChanged(this, e); - } + SelectionChanged?.Invoke(this, e); } } @@ -735,59 +189,33 @@ private void List_Loaded(object sender, RoutedEventArgs e) private void ScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e) { - int packagesCount = Items.Count; - - if (Items.Contains(_loadingStatusIndicator)) - { - packagesCount--; - } - - if (Items.Contains(_loadingVulnerabilitiesStatusIndicator)) - { - packagesCount--; - } - - if (_loader?.State.LoadingStatus == LoadingStatus.Ready) + if (ViewModel.Loader?.State.LoadingStatus == LoadingStatus.Ready) { var first = _scrollViewer.VerticalOffset; var last = _scrollViewer.ViewportHeight + first; + + int packagesCount = ViewModel.PackageItemsCount; + if (_scrollViewer.ViewportHeight > 0 && last >= packagesCount) { - _ = LoadItemsAsync(selectedPackageItem: null, token: CancellationToken.None); + _ = ViewModel.LoadMoreItemsAsync(); } } } private void SelectAllPackagesCheckBox_Checked(object sender, RoutedEventArgs e) { - foreach (var item in _list.Items) - { - var package = item as PackageItemViewModel; - - // note that item could be the loading indicator, thus we need to check - // for null here. - if (package != null) - { - package.IsSelected = true; - } - } + ViewModel.SelectAll(); } private void SelectAllPackagesCheckBox_Unchecked(object sender, RoutedEventArgs e) { - foreach (var item in _list.Items) - { - var package = item as PackageItemViewModel; - if (package != null) - { - package.IsSelected = false; - } - } + ViewModel.DeselectAll(); } private void _updateButton_Click(object sender, RoutedEventArgs e) { - var selectedPackages = PackageItems.Where(p => p.IsSelected).ToArray(); + var selectedPackages = ViewModel.GetSelectedPackages(); UpdateButtonClicked(selectedPackages); } @@ -795,7 +223,6 @@ private void List_PreviewKeyUp(object sender, System.Windows.Input.KeyEventArgs { if (e.Key == Key.Space && e.OriginalSource is ListBoxItem && _list.SelectedItem is PackageItemViewModel package) { - // toggle the selection state when user presses the space bar package.IsSelected = !package.IsSelected; e.Handled = true; } @@ -803,53 +230,12 @@ private void List_PreviewKeyUp(object sender, System.Windows.Input.KeyEventArgs private void _loadingStatusBar_ShowMoreResultsClick(object sender, RoutedEventArgs e) { - var packageItems = _loader?.GetCurrent() ?? Enumerable.Empty(); - UpdatePackageList(packageItems, refresh: true); - _loadingStatusBar.ItemsLoaded = _loader?.State.ItemsCount ?? 0; - - var desiredVisibility = EvaluateStatusBarVisibility(_loader, _loader.State); - if (_loadingStatusBar.Visibility != desiredVisibility) - { - _loadingStatusBar.Visibility = desiredVisibility; - } + ViewModel.ShowMoreResults(); } private void _loadingStatusBar_DismissClick(object sender, RoutedEventArgs e) { - _loadingStatusBar.Visibility = Visibility.Hidden; - } - - public void ResetLoadingStatusIndicator() - { - _loadingStatusIndicator.Reset(string.Empty); - } - - internal void ClearPackageLevelGrouping() - { - ItemsView.GroupDescriptions.Clear(); - } - - internal void AddVulnerabilitiesFiltering() - { - _filterByVulnerabilities = true; - ItemsView.Refresh(); - } - - internal void RemoveVulnerabilitiesFiltering() - { - _filterByVulnerabilities = false; - ItemsView.Refresh(); - } - - internal void AddPackageLevelGrouping() - { - ItemsView.Refresh(); - if (Items - .OfType() - .Any(p => p.PackageLevel == PackageLevel.Transitive)) - { - ItemsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PackageItemViewModel.PackageLevel))); - } + ViewModel.HasStatusBarContent = false; } private void Expander_ExpansionStateToggled(object sender, RoutedEventArgs e) diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs index a81dfdc7111..b85438cbdf0 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs @@ -188,8 +188,8 @@ private async ValueTask InitializeAsync(PackageManagerModel model, INuGetUILogge await IsCentralPackageManagementEnabledAsync(CancellationToken.None); // UI is initialized. Start the first search - _packageList.CheckBoxesEnabled = _topPanel.Filter == ItemFilter.UpdatesAvailable; - _packageList.IsSolution = Model.IsSolution; + _packageList.ViewModel.IsUpdateMode = _topPanel.Filter == ItemFilter.UpdatesAvailable; + _packageList.ViewModel.IsSolution = Model.IsSolution; Loaded += PackageManagerLoaded; @@ -965,7 +965,7 @@ internal async Task SearchPackagesAndRefreshUpdateCountAsync(string searchText, // start SearchAsync task for initial loading of packages var searchResultTask = loader.SearchAsync(cancellationToken: _loadCts.Token); // this will wait for searchResultTask to complete instead of creating a new task - await _packageList.LoadItemsAsync(loader, loadingMessage, _uiLogger, searchResultTask, _loadCts.Token); + await _packageList.ViewModel.LoadItemsAsync(loader, loadingMessage, _uiLogger, searchResultTask, _loadCts.Token); if (ActiveFilter == ItemFilter.Installed) { @@ -1153,9 +1153,9 @@ private void PackageList_SelectionChanged(object sender, SelectionChangedEventAr /// internal async Task UpdateDetailPaneAsync(CancellationToken cancellationToken) { - PackageItemViewModel selectedItem = _packageList.SelectedItem; + PackageItemViewModel selectedItem = _packageList.ViewModel.SelectedPackageItem; int selectedIndex = _packageList.SelectedIndex; - int recommendedCount = _packageList.PackageItems.Count(item => item.Recommended == true); + int recommendedCount = _packageList.ViewModel.PackageItems.Count(item => item.Recommended == true); if (selectedItem == null) { @@ -1168,7 +1168,7 @@ internal async Task UpdateDetailPaneAsync(CancellationToken cancellationToken) EmitSearchSelectionTelemetry(selectedItem); - await _detailModel.SetCurrentPackageAsync(selectedItem, _topPanel.Filter, () => _packageList.SelectedItem, cancellationToken); + await _detailModel.SetCurrentPackageAsync(selectedItem, _topPanel.Filter, () => _packageList.ViewModel.SelectedPackageItem, cancellationToken); Model.UIController.SelectedPackageId = selectedItem.Id; _detailModel.SetCurrentSelectionInfo(selectedIndex, recommendedCount, _recommendPackages, selectedItem.RecommenderVersion); @@ -1183,9 +1183,9 @@ internal async Task UpdateDetailPaneAsync(CancellationToken cancellationToken) private void EmitSearchSelectionTelemetry(PackageItemViewModel selectedPackage) { - var operationId = _packageList.OperationId; + var operationId = _packageList.ViewModel.OperationId; var selectedIndex = _packageList.SelectedIndex; - var recommendedCount = _packageList.PackageItems.Count(item => item.Recommended == true); + var recommendedCount = _packageList.ViewModel.PackageItems.Count(item => item.Recommended == true); var hasDeprecationAlternative = selectedPackage.AlternatePackage != null; if (_topPanel.Filter == ItemFilter.All @@ -1206,7 +1206,7 @@ private void EmitSearchSelectionTelemetry(PackageItemViewModel selectedPackage) private void IncrementInstalledPackageSelectionCount() { - PackageItemViewModel selectedItem = _packageList.SelectedItem; + PackageItemViewModel selectedItem = _packageList.ViewModel.SelectedPackageItem; if (selectedItem == null || ActiveFilter != ItemFilter.Installed) { return; @@ -1287,11 +1287,10 @@ private void Filter_SelectionChanged(object sender, FilterChangedEventArgs e) if (_initialized) { var timeSpan = GetTimeSinceLastRefreshAndRestart(); - _packageList.ResetLoadingStatusIndicator(); + _packageList.ViewModel.ResetLoadingStatusIndicator(); var sw = Stopwatch.StartNew(); // Collapse the Update controls when the current tab is not "Updates". - _packageList.CheckBoxesEnabled = _topPanel.Filter == ItemFilter.UpdatesAvailable; - _packageList._updateButtonContainer.Visibility = _topPanel.Filter == ItemFilter.UpdatesAvailable ? Visibility.Visible : Visibility.Collapsed; + _packageList.ViewModel.IsUpdateMode = _topPanel.Filter == ItemFilter.UpdatesAvailable; // Set a new cancellation token source which will be used to cancel this task in case // new loading task starts or manager ui is closed while loading packages. @@ -1331,7 +1330,7 @@ private async ValueTask RefreshAsync(bool clearCache = false) Model.Context.ServiceBroker, Model.Context.Projects, CancellationToken.None); - await _packageList.UpdatePackageStatusAsync(installedPackages.ToArray(), CancellationToken.None, clearCache); + await _packageList.ViewModel.UpdatePackageStatusAsync(installedPackages.ToArray(), CancellationToken.None, clearCache); await RefreshInstalledAndUpdatesTabsAsync(); } @@ -1527,10 +1526,10 @@ public void ShowUpdatePackages(ShowUpdatePackageOptions updatePackageOptions) EventHandler handler = null; handler = (s, e) => { - _packageList.LoadItemsCompleted -= handler; + _packageList.ViewModel.LoadItemsCompleted -= handler; SelectMatchingUpdatePackages(updatePackageOptions); }; - _packageList.LoadItemsCompleted += handler; + _packageList.ViewModel.LoadItemsCompleted += handler; } } @@ -1545,7 +1544,7 @@ private void SelectMatchingUpdatePackages(ShowUpdatePackageOptions updatePackage if (updatePackageOptions.ShouldUpdateAllPackages) { - foreach (var packageItem in _packageList.PackageItems) + foreach (var packageItem in _packageList.ViewModel.PackageItems) { packageItem.IsSelected = true; } @@ -1554,7 +1553,7 @@ private void SelectMatchingUpdatePackages(ShowUpdatePackageOptions updatePackage { var packagesToSelect = new HashSet(updatePackageOptions.PackagesToUpdate); PackageItemViewModel firstSelectedItem = null; - foreach (var packageItem in _packageList.PackageItems) + foreach (var packageItem in _packageList.ViewModel.PackageItems) { packageItem.IsSelected = packagesToSelect.Contains(packageItem.Id, StringComparer.OrdinalIgnoreCase); diff --git a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/GlobalSuppressions.cs b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/GlobalSuppressions.cs index 148f204c35a..e4e4deb4f01 100644 --- a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/GlobalSuppressions.cs +++ b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/GlobalSuppressions.cs @@ -10,3 +10,4 @@ [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Unit-test names don't have to follow naming style")] [assembly: SuppressMessage("Usage", "xUnit1041:Fixture arguments to test classes must have fixture sources", Justification = "", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.Test.Models.LocalPackageDetailControlModelTests.#ctor(Microsoft.VisualStudio.Sdk.TestFramework.GlobalServiceProvider,NuGet.Test.Utility.LocalPackageSearchMetadataFixture)")] +[assembly: SuppressMessage("Usage", "VSSDK005:Avoid instantiating JoinableTaskContext", Justification = "Test requires JoinableTaskFactory without VS service provider", Scope = "member", Target = "~M:NuGet.PackageManagement.UI.Test.ViewModels.InfiniteScrollListViewModelTests.#ctor")] diff --git a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/ViewModels/InfiniteScrollListViewModelTests.cs b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/ViewModels/InfiniteScrollListViewModelTests.cs new file mode 100644 index 00000000000..66ded0c25ef --- /dev/null +++ b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/ViewModels/InfiniteScrollListViewModelTests.cs @@ -0,0 +1,608 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Threading; +using Moq; +using NuGet.PackageManagement.UI.Models.Package; +using NuGet.PackageManagement.UI.Test.Models.Package; +using NuGet.PackageManagement.UI.ViewModels; +using NuGet.PackageManagement.VisualStudio; +using NuGet.Packaging.Core; +using NuGet.Versioning; +using NuGet.VisualStudio; +using NuGet.VisualStudio.Internal.Contracts; +using Xunit; + +namespace NuGet.PackageManagement.UI.Test.ViewModels +{ + public class InfiniteScrollListViewModelTests : IDisposable + { + private readonly JoinableTaskContext _joinableTaskContext; + private readonly JoinableTaskFactory _joinableTaskFactory; + + public InfiniteScrollListViewModelTests() + { + _joinableTaskContext = new JoinableTaskContext(); + _joinableTaskFactory = _joinableTaskContext.Factory; + } + + public void Dispose() + { + _joinableTaskContext.Dispose(); + } + + private InfiniteScrollListViewModel CreateViewModel() + { + return new InfiniteScrollListViewModel( + new Lazy(() => _joinableTaskFactory)); + } + + private static PackageItemViewModel CreatePackageItemViewModel( + string id = "TestPackage", + string version = "1.0.0", + PackageLevel packageLevel = PackageLevel.TopLevel, + bool isVulnerable = false) + { + var searchService = new Mock(); + var packageIdentity = new PackageIdentity(id, new NuGetVersion(version)); + var embeddedResource = new Mock(); + var vulnerableCapability = new Mock(); + vulnerableCapability.SetupGet(v => v.IsVulnerable).Returns(isVulnerable); + var deprecatedCapability = new Mock(); + var packageModel = PackageModelCreationTestHelper.CreateRemotePackageModel( + packageIdentity, vulnerableCapability.Object, deprecatedCapability.Object, embeddedResource.Object); + + var vm = new PackageItemViewModel(searchService.Object, packageModel: packageModel) + { + PackageLevel = packageLevel, + }; + return vm; + } + + /// + /// Creates a mock loader that transitions from Loading → finalStatus on first UpdateStateAndReportAsync call. + /// + private static Mock CreateMockLoader( + LoadingStatus finalStatus, + IEnumerable results = null, + bool isMultiSource = false) + { + var loader = new Mock(MockBehavior.Loose); + var state = new Mock(); + var currentStatus = LoadingStatus.Loading; + + state.Setup(x => x.LoadingStatus).Returns(() => currentStatus); + state.Setup(x => x.ItemsCount).Returns(() => results?.Count() ?? 0); + state.Setup(x => x.SourceLoadingStatus).Returns(new Dictionary()); + + loader.SetupGet(x => x.State).Returns(state.Object); + loader.SetupGet(x => x.IsMultiSource).Returns(isMultiSource); + loader.Setup(x => x.GetCurrent()).Returns(results ?? Enumerable.Empty()); + loader.Setup(x => x.UpdateStateAndReportAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask) + .Callback(() => currentStatus = finalStatus); + loader.Setup(x => x.UpdateStateAsync( + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask) + .Callback(() => { if (currentStatus == LoadingStatus.Ready) currentStatus = LoadingStatus.NoMoreItems; }); + + return loader; + } + + /// + /// Runs a full load cycle and waits for LoadItemsCompleted. + /// + private async Task LoadAndWaitAsync(InfiniteScrollListViewModel vm, Mock loader) + { + var tcs = new TaskCompletionSource(); + void handler(object s, EventArgs e) => tcs.TrySetResult(true); + vm.LoadItemsCompleted += handler; + try + { + await vm.LoadItemsAsync( + loader.Object, + loadingMessage: "Loading...", + logger: Mock.Of(), + searchResultTask: Task.FromResult(new SearchResultContextInfo()), + token: CancellationToken.None); + await tcs.Task; + } + finally + { + vm.LoadItemsCompleted -= handler; + } + } + + [Fact] + public void Constructor_JoinableTaskFactoryIsNull_Throws() + { + var exception = Assert.Throws( + () => new InfiniteScrollListViewModel(joinableTaskFactory: null)); + + Assert.Equal("joinableTaskFactory", exception.ParamName); + } + + [Fact] + public void Constructor_DefaultState_PropertiesHaveExpectedDefaults() + { + var vm = CreateViewModel(); + + Assert.Empty(vm.Items); + Assert.Empty(vm.PackageItems); + Assert.False(vm.IsUpdateMode); + Assert.False(vm.IsSolution); + Assert.Equal(0, vm.SelectedCount); + Assert.Null(vm.OperationId); + Assert.False(vm.HasUpdatablePackages); + Assert.False(vm.HasSelectedPackages); + Assert.False(vm.HasStatusBarContent); + Assert.Equal(0, vm.ItemsLoaded); + } + + [Fact] + public async Task LoadItemsAsync_LoaderIsNull_Throws() + { + var vm = CreateViewModel(); + + await Assert.ThrowsAsync(async () => + await vm.LoadItemsAsync( + loader: null, + loadingMessage: "Loading...", + logger: Mock.Of(), + searchResultTask: Task.FromResult(new SearchResultContextInfo()), + token: CancellationToken.None)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task LoadItemsAsync_LoadingMessageIsNullOrEmpty_Throws(string loadingMessage) + { + var vm = CreateViewModel(); + + await Assert.ThrowsAsync(async () => + await vm.LoadItemsAsync( + Mock.Of(), + loadingMessage, + logger: Mock.Of(), + searchResultTask: Task.FromResult(new SearchResultContextInfo()), + token: CancellationToken.None)); + } + + [Fact] + public async Task LoadItemsAsync_SearchResultTaskIsNull_Throws() + { + var vm = CreateViewModel(); + + await Assert.ThrowsAsync(async () => + await vm.LoadItemsAsync( + Mock.Of(), + loadingMessage: "Loading...", + logger: Mock.Of(), + searchResultTask: null, + token: CancellationToken.None)); + } + + [Fact] + public async Task LoadItemsAsync_CancelledToken_Throws() + { + var vm = CreateViewModel(); + + await Assert.ThrowsAsync(async () => + await vm.LoadItemsAsync( + Mock.Of(), + loadingMessage: "Loading...", + logger: Mock.Of(), + searchResultTask: Task.FromResult(new SearchResultContextInfo()), + token: new CancellationToken(canceled: true))); + } + + [Fact] + public async Task LoadItemsAsync_WithNoResults_SetsNoItemsFoundStatus() + { + var vm = CreateViewModel(); + var loader = CreateMockLoader(LoadingStatus.NoItemsFound); + + await LoadAndWaitAsync(vm, loader); + + Assert.Contains(vm.Items, item => item is LoadingStatusIndicator); + Assert.Empty(vm.PackageItems); + } + + [Fact] + public async Task LoadItemsAsync_WithResults_AddsItemsToCollection() + { + var vm = CreateViewModel(); + var packages = new[] + { + CreatePackageItemViewModel("PackageA"), + CreatePackageItemViewModel("PackageB"), + CreatePackageItemViewModel("PackageC"), + }; + var loader = CreateMockLoader(LoadingStatus.Ready, packages); + + await LoadAndWaitAsync(vm, loader); + + Assert.Equal(3, vm.PackageItems.Count()); + Assert.Equal(3, vm.PackageItemsCount); + } + + [Fact] + public async Task LoadItemsAsync_ClearsExistingItems() + { + var vm = CreateViewModel(); + + // First load + var packages1 = new[] { CreatePackageItemViewModel("OldPackage") }; + var loader1 = CreateMockLoader(LoadingStatus.Ready, packages1); + await LoadAndWaitAsync(vm, loader1); + Assert.Equal("OldPackage", vm.PackageItems.First().Id); + + // Second load — should replace old items + var packages2 = new[] { CreatePackageItemViewModel("NewPackage") }; + var loader2 = CreateMockLoader(LoadingStatus.Ready, packages2); + await LoadAndWaitAsync(vm, loader2); + + Assert.Single(vm.PackageItems); + Assert.Equal("NewPackage", vm.PackageItems.First().Id); + } + + [Fact] + public async Task LoadItemsAsync_SetsHasStatusBarContentToFalse() + { + var vm = CreateViewModel(); + vm.HasStatusBarContent = true; // pre-condition + + var loader = CreateMockLoader(LoadingStatus.Ready, new[] { CreatePackageItemViewModel("A") }); + await LoadAndWaitAsync(vm, loader); + + Assert.False(vm.HasStatusBarContent); + } + + [Fact] + public void SelectAll_SetsAllPackagesSelected() + { + var vm = CreateViewModel(); + var pkg1 = CreatePackageItemViewModel("A"); + var pkg2 = CreatePackageItemViewModel("B"); + vm.UpdatePackageList(new[] { pkg1, pkg2 }, refresh: false); + + vm.SelectAll(); + + Assert.True(pkg1.IsSelected); + Assert.True(pkg2.IsSelected); + } + + [Fact] + public void DeselectAll_ClearsSelection() + { + var vm = CreateViewModel(); + var pkg1 = CreatePackageItemViewModel("A"); + var pkg2 = CreatePackageItemViewModel("B"); + vm.UpdatePackageList(new[] { pkg1, pkg2 }, refresh: false); + vm.SelectAll(); + + vm.DeselectAll(); + + Assert.False(pkg1.IsSelected); + Assert.False(pkg2.IsSelected); + } + + [Fact] + public void GetSelectedPackages_ReturnsOnlySelected() + { + var vm = CreateViewModel(); + var pkg1 = CreatePackageItemViewModel("A"); + var pkg2 = CreatePackageItemViewModel("B"); + var pkg3 = CreatePackageItemViewModel("C"); + vm.UpdatePackageList(new[] { pkg1, pkg2, pkg3 }, refresh: false); + pkg1.IsSelected = true; + pkg3.IsSelected = true; + + var selected = vm.GetSelectedPackages(); + + Assert.Equal(2, selected.Length); + Assert.Contains(selected, p => p.Id == "A"); + Assert.Contains(selected, p => p.Id == "C"); + } + + [Fact] + public void SelectionTracking_TracksSelectedCount() + { + var vm = CreateViewModel(); + var pkg1 = CreatePackageItemViewModel("A"); + var pkg2 = CreatePackageItemViewModel("B"); + vm.UpdatePackageList(new[] { pkg1, pkg2 }, refresh: false); + + Assert.Equal(0, vm.SelectedCount); + + pkg1.IsSelected = true; + Assert.Equal(1, vm.SelectedCount); + + pkg2.IsSelected = true; + Assert.Equal(2, vm.SelectedCount); + + pkg1.IsSelected = false; + Assert.Equal(1, vm.SelectedCount); + } + + [Fact] + public void IsUpdateMode_WhenSetToFalse_CollapsesUpdateContainer() + { + var vm = CreateViewModel(); + vm.UpdatePackageList(new[] { CreatePackageItemViewModel("A") }, refresh: false); + vm.IsUpdateMode = true; + Assert.True(vm.HasUpdatablePackages); + + vm.IsUpdateMode = false; + + Assert.False(vm.HasUpdatablePackages); + } + + [Fact] + public void UpdateSelectionState_AllSelected_SetsCheckedState() + { + var vm = CreateViewModel(); + vm.IsUpdateMode = true; + var pkg1 = CreatePackageItemViewModel("A"); + var pkg2 = CreatePackageItemViewModel("B"); + vm.UpdatePackageList(new[] { pkg1, pkg2 }, refresh: false); + + pkg1.IsSelected = true; + pkg2.IsSelected = true; + + Assert.True(vm.HasUpdatablePackages); + Assert.True(vm.SelectionState); + Assert.True(vm.HasSelectedPackages); + } + + [Fact] + public void UpdateSelectionState_SomeSelected_SetsIndeterminateState() + { + var vm = CreateViewModel(); + vm.IsUpdateMode = true; + var pkg1 = CreatePackageItemViewModel("A"); + var pkg2 = CreatePackageItemViewModel("B"); + vm.UpdatePackageList(new[] { pkg1, pkg2 }, refresh: false); + + pkg1.IsSelected = true; + + Assert.True(vm.HasUpdatablePackages); + Assert.Null(vm.SelectionState); + Assert.True(vm.HasSelectedPackages); + } + + [Fact] + public void UpdateSelectionState_NoneSelected_SetsUncheckedAndDisabled() + { + var vm = CreateViewModel(); + vm.UpdatePackageList(new[] { CreatePackageItemViewModel("A") }, refresh: false); + vm.IsUpdateMode = true; + + Assert.True(vm.HasUpdatablePackages); + Assert.False(vm.SelectionState); + Assert.False(vm.HasSelectedPackages); + } + + [Fact] + public void FilterItem_PackageViewModel_ReturnsTrue() + { + var vm = CreateViewModel(); + var package = CreatePackageItemViewModel("A"); + + Assert.True(vm.FilterItem(package)); + } + + [Fact] + public void FilterVulnerablePackage_WhenFilterDisabled_IncludesNonVulnerable() + { + var vm = CreateViewModel(); + var package = CreatePackageItemViewModel("A", isVulnerable: false); + Assert.False(package.IsPackageVulnerable); + + Assert.True(vm.FilterVulnerablePackage(package)); + } + + [Fact] + public void FilterVulnerablePackage_WhenFilterEnabled_HidesNonVulnerable() + { + var vm = CreateViewModel(); + vm.SetVulnerabilitiesFiltering(true); + var package = CreatePackageItemViewModel("A", isVulnerable: false); + Assert.False(package.IsPackageVulnerable); + + Assert.False(vm.FilterVulnerablePackage(package)); + } + + [Fact] + public void FilterVulnerablePackage_WhenFilterEnabled_IncludesVulnerable() + { + var vm = CreateViewModel(); + vm.SetVulnerabilitiesFiltering(true); + var package = CreatePackageItemViewModel("A", isVulnerable: true); + Assert.True(package.IsPackageVulnerable); + + Assert.True(vm.FilterVulnerablePackage(package)); + } + + [Fact] + public void FilterLoadingIndicator_WhenNotFilteringVulnerabilities_ShowsIndicator() + { + var vm = CreateViewModel(); + + Assert.True(vm.FilterLoadingIndicator(vm.LoadingStatusIndicator)); + } + + [Fact] + public void FilterLoadingIndicator_WhenFilteringVulnerabilities_HidesIndicator() + { + var vm = CreateViewModel(); + vm.SetVulnerabilitiesFiltering(true); + + Assert.False(vm.FilterLoadingIndicator(vm.LoadingStatusIndicator)); + } + + [Fact] + public void HasTransitiveItems_WithNoTransitive_ReturnsFalse() + { + var vm = CreateViewModel(); + vm.UpdatePackageList(new[] { CreatePackageItemViewModel("A", packageLevel: PackageLevel.TopLevel) }, refresh: false); + + Assert.False(vm.HasTransitiveItems()); + } + + [Fact] + public void HasTransitiveItems_WithTransitive_ReturnsTrue() + { + var vm = CreateViewModel(); + vm.UpdatePackageList(new[] + { + CreatePackageItemViewModel("A", packageLevel: PackageLevel.TopLevel), + CreatePackageItemViewModel("B", packageLevel: PackageLevel.Transitive), + }, refresh: false); + + Assert.True(vm.HasTransitiveItems()); + } + + [Fact] + public void ResolveSelectedItem_WhenPreviousItemExists_ReturnsSameById() + { + var vm = CreateViewModel(); + var pkg1 = CreatePackageItemViewModel("A"); + var pkg2 = CreatePackageItemViewModel("B"); + vm.UpdatePackageList(new[] { pkg1, pkg2 }, refresh: false); + + var result = vm.ResolveSelectedItem(CreatePackageItemViewModel("B")); + + Assert.Same(pkg2, result); + } + + [Fact] + public void ResolveSelectedItem_WhenPreviousItemNotFound_ReturnsFirst() + { + var vm = CreateViewModel(); + var pkg1 = CreatePackageItemViewModel("A"); + vm.UpdatePackageList(new[] { pkg1 }, refresh: false); + + var result = vm.ResolveSelectedItem(CreatePackageItemViewModel("NotInList")); + + Assert.Same(pkg1, result); + } + + [Fact] + public void ResolveSelectedItem_WhenNull_ReturnsFirst() + { + var vm = CreateViewModel(); + var pkg1 = CreatePackageItemViewModel("A"); + vm.UpdatePackageList(new[] { pkg1 }, refresh: false); + + var result = vm.ResolveSelectedItem(null); + + Assert.Same(pkg1, result); + } + + [Fact] + public void ShouldShowStatusBar_WhenCancelled_ReturnsTrue() + { + var vm = CreateViewModel(); + var state = new Mock(); + state.Setup(x => x.LoadingStatus).Returns(LoadingStatus.Cancelled); + + Assert.True(vm.ShouldShowStatusBar(state.Object)); + } + + [Fact] + public void ShouldShowStatusBar_WhenErrorOccurred_ReturnsTrue() + { + var vm = CreateViewModel(); + var state = new Mock(); + state.Setup(x => x.LoadingStatus).Returns(LoadingStatus.ErrorOccurred); + + Assert.True(vm.ShouldShowStatusBar(state.Object)); + } + + [Fact] + public void ShouldShowStatusBar_WhenReady_ReturnsFalse() + { + var vm = CreateViewModel(); + var state = new Mock(); + state.Setup(x => x.LoadingStatus).Returns(LoadingStatus.Ready); + + Assert.False(vm.ShouldShowStatusBar(state.Object)); + } + + [Fact] + public void ShouldShowStatusBar_WhenNull_ReturnsFalse() + { + var vm = CreateViewModel(); + Assert.False(vm.ShouldShowStatusBar(null)); + } + + [Fact] + public void ResetLoadingStatusIndicator_ResetsStatus() + { + var vm = CreateViewModel(); + vm.LoadingStatusIndicator.Status = LoadingStatus.ErrorOccurred; + + vm.ResetLoadingStatusIndicator(); + + Assert.Equal(LoadingStatus.Unknown, vm.LoadingStatusIndicator.Status); + } + + [Fact] + public void LoadingStatusLocalizedText_ReflectsIndicatorStatus() + { + var vm = CreateViewModel(); + vm.LoadingStatusIndicator.Status = LoadingStatus.NoItemsFound; + + Assert.Equal(Resources.Text_NoPackagesFound, vm.LoadingStatusLocalizedText); + } + + [Fact] + public void ClearPackageList_RemovesAllItemsAndResetsItemsLoaded() + { + var vm = CreateViewModel(); + vm.UpdatePackageList(new[] { CreatePackageItemViewModel("A") }, refresh: false); + Assert.NotEmpty(vm.Items); + + vm.ClearPackageList(); + + Assert.Empty(vm.Items); + Assert.Equal(0, vm.ItemsLoaded); + } + + [Fact] + public void UpdatePackageList_RefreshTrue_ClearsAndReloads() + { + var vm = CreateViewModel(); + vm.UpdatePackageList(new[] { CreatePackageItemViewModel("OldPkg") }, refresh: false); + + vm.UpdatePackageList(new[] { CreatePackageItemViewModel("NewPkg") }, refresh: true); + + Assert.Single(vm.PackageItems); + Assert.Equal("NewPkg", vm.PackageItems.First().Id); + } + + [Fact] + public void UpdatePackageList_RefreshFalse_Appends() + { + var vm = CreateViewModel(); + vm.UpdatePackageList(new[] { CreatePackageItemViewModel("A") }, refresh: false); + + vm.UpdatePackageList(new[] { CreatePackageItemViewModel("B") }, refresh: false); + + Assert.Equal(2, vm.PackageItems.Count()); + } + } +} diff --git a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/Xamls/InfiniteScrollListTests.cs b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/Xamls/InfiniteScrollListTests.cs index ce5b3099e0d..116783c769e 100644 --- a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/Xamls/InfiniteScrollListTests.cs +++ b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/Xamls/InfiniteScrollListTests.cs @@ -12,6 +12,7 @@ using NuGet.Common; using NuGet.PackageManagement.UI.Models.Package; using NuGet.PackageManagement.UI.Test.Models.Package; +using NuGet.PackageManagement.UI.ViewModels; using NuGet.PackageManagement.VisualStudio; using NuGet.Packaging.Core; using NuGet.Versioning; @@ -43,11 +44,11 @@ public void Constructor_JoinableTaskFactoryIsNull_Throws() } [WpfFact(Skip = "https://github.com/NuGet/Home/issues/10938")] - public void CheckBoxesEnabled_Initialized_DefaultIsFalse() + public void IsUpdateMode_Initialized_DefaultIsFalse() { var list = new InfiniteScrollList(); - Assert.False(list.CheckBoxesEnabled); + Assert.False(list.ViewModel.IsUpdateMode); } [WpfFact(Skip = "https://github.com/NuGet/Home/issues/10938")] @@ -55,7 +56,7 @@ public void DataContext_Initialized_DefaultIsItems() { var list = new InfiniteScrollList(); - Assert.Same(list.DataContext, list.Items); + Assert.Same(list.DataContext, list.ViewModel.Items); } [WpfFact(Skip = "https://github.com/NuGet/Home/issues/10938")] @@ -63,7 +64,7 @@ public void IsSolution_Initialized_DefaultIsFalse() { var list = new InfiniteScrollList(); - Assert.False(list.IsSolution); + Assert.False(list.ViewModel.IsSolution); } [WpfFact(Skip = "https://github.com/NuGet/Home/issues/10938")] @@ -71,7 +72,7 @@ public void Items_Initialized_DefaultIsEmpty() { var list = new InfiniteScrollList(); - Assert.Empty(list.Items); + Assert.Empty(list.ViewModel.Items); } [WpfFact(Skip = "https://github.com/NuGet/Home/issues/10938")] @@ -79,7 +80,7 @@ public void PackageItems_Initialized_DefaultIsEmpty() { var list = new InfiniteScrollList(); - Assert.Empty(list.PackageItems); + Assert.Empty(list.ViewModel.PackageItems); } [WpfFact(Skip = "https://github.com/NuGet/Home/issues/10938")] @@ -87,7 +88,7 @@ public void SelectedPackageItem_Initialized_DefaultIsNull() { var list = new InfiniteScrollList(); - Assert.Null(list.SelectedPackageItem); + Assert.Null(list.ViewModel.SelectedPackageItem); } [WpfFact(Skip = "https://github.com/NuGet/Home/issues/10938")] @@ -98,7 +99,7 @@ public async Task LoadItems_LoaderIsNull_Throws() var exception = await Assert.ThrowsAsync( async () => { - await list.LoadItemsAsync( + await list.ViewModel.LoadItemsAsync( loader: null, loadingMessage: "a", logger: null, @@ -119,7 +120,7 @@ public async Task LoadItems_LoadingMessageIsNullOrEmpty_Throws(string? loadingMe var exception = await Assert.ThrowsAsync( async () => { - await list.LoadItemsAsync( + await list.ViewModel.LoadItemsAsync( Mock.Of(), loadingMessage, logger: null, @@ -138,7 +139,7 @@ public async Task LoadItems_SearchResultTaskIsNull_Throws() var exception = await Assert.ThrowsAsync( async () => { - await list.LoadItemsAsync( + await list.ViewModel.LoadItemsAsync( Mock.Of(), loadingMessage: "a", logger: null, @@ -157,7 +158,7 @@ public async Task LoadItems_IfCancelled_Throws() await Assert.ThrowsAsync( async () => { - await list.LoadItemsAsync( + await list.ViewModel.LoadItemsAsync( Mock.Of(), loadingMessage: "a", logger: null, @@ -231,7 +232,7 @@ public async Task LoadItems_BeforeGettingCurrent_WaitsForInitialResults() // Despite LoadItems(...) being a synchronous method, the method internally fires an asynchronous task. // We'll know when that task completes successfully when the LoadItemsCompleted event fires, // and to avoid infinite waits in exceptional cases, we'll interpret a call to reset as a failure. - list.LoadItemsCompleted += (sender, args) => taskCompletionSource.TrySetResult(null); + list.ViewModel.LoadItemsCompleted += (sender, args) => taskCompletionSource.TrySetResult(null); loader.Setup(x => x.Reset()); logger.Setup(x => x.Log(It.Is(lm => lm.Level == LogLevel.Error && lm.Message != null))) @@ -251,7 +252,7 @@ public async Task LoadItems_BeforeGettingCurrent_WaitsForInitialResults() return Enumerable.Empty(); }); - await list.LoadItemsAsync( + await list.ViewModel.LoadItemsAsync( loader.Object, loadingMessage: "a", logger: logger.Object, @@ -312,15 +313,15 @@ public async Task LoadItemsAsync_LoadingStatusIndicator_InItemsCollectionIfEmpty loaderMock.Setup(x => x.GetCurrent()) .Returns(() => searchItems.Select(x => new PackageItemViewModel(searchService.Object, packageModel: packageModel))); - list.LoadItemsCompleted += (sender, args) => + list.ViewModel.LoadItemsCompleted += (sender, args) => { - var lst = (InfiniteScrollList)sender; - tcs.TrySetResult(lst.Items.Count); + var vm = (InfiniteScrollListViewModel)sender; + tcs.TrySetResult(vm.Items.Count); _output.WriteLine("3. After assert"); }; _output.WriteLine("1. Init act"); - await list.LoadItemsAsync( + await list.ViewModel.LoadItemsAsync( loader: loaderMock.Object, loadingMessage: "Test loading", logger: testLogger, diff --git a/test/NuGet.Tests.Apex/NuGet.PackageManagement.UI.TestContract/ApexTestUIProject.cs b/test/NuGet.Tests.Apex/NuGet.PackageManagement.UI.TestContract/ApexTestUIProject.cs index 200534ce31f..66c2eef7c75 100644 --- a/test/NuGet.Tests.Apex/NuGet.PackageManagement.UI.TestContract/ApexTestUIProject.cs +++ b/test/NuGet.Tests.Apex/NuGet.PackageManagement.UI.TestContract/ApexTestUIProject.cs @@ -24,7 +24,7 @@ public IEnumerable PackageItems { get { - return UIInvoke(() => _packageManagerControl.PackageList.PackageItems); + return UIInvoke(() => _packageManagerControl.PackageList.ViewModel.PackageItems); } } @@ -32,11 +32,11 @@ public PackageItemViewModel SelectedPackage { get { - return UIInvoke(() => _packageManagerControl.PackageList.SelectedItem); + return UIInvoke(() => _packageManagerControl.PackageList.ViewModel.SelectedPackageItem); } set { - UIInvoke(() => _packageManagerControl.PackageList.SelectedItem = value); + UIInvoke(() => _packageManagerControl.PackageList.ViewModel.SelectedPackageItem = value); } } @@ -69,7 +69,7 @@ public void Search(string searchText) public bool VerifyFirstPackageOnTab(string tabName, string packageId, string packageVersion = null) { - var result = UIInvoke(() => _packageManagerControl.PackageList.PackageItems.FirstOrDefault()); + var result = UIInvoke(() => _packageManagerControl.PackageList.ViewModel.PackageItems.FirstOrDefault()); if (result is null) { return false; @@ -87,19 +87,19 @@ public bool VerifyFirstPackageOnTab(string tabName, string packageId, string pac public bool VerifyVulnerablePackageOnTopOfInstalledTab() { - var result = UIInvoke(() => _packageManagerControl.PackageList.PackageItems.FirstOrDefault()); + var result = UIInvoke(() => _packageManagerControl.PackageList.ViewModel.PackageItems.FirstOrDefault()); return result?.IsPackageVulnerable == true; } public bool VerifyDeprecatedPackageOnTopOfInstalledTab() { - var result = UIInvoke(() => _packageManagerControl.PackageList.PackageItems.FirstOrDefault()); + var result = UIInvoke(() => _packageManagerControl.PackageList.ViewModel.PackageItems.FirstOrDefault()); return result?.IsPackageDeprecated == true; } public List GetPackageItemsOnInstalledTab() { - return _packageManagerControl.PackageList.PackageItems.ToList(); + return _packageManagerControl.PackageList.ViewModel.PackageItems.ToList(); } public void InstallPackage(string packageId, string version) @@ -151,7 +151,7 @@ public bool WaitForSearchComplete(Action search, TimeSpan timeout) try { - _packageManagerControl.PackageList.LoadItemsCompleted += eventHandler; + _packageManagerControl.PackageList.ViewModel.LoadItemsCompleted += eventHandler; search(); @@ -166,7 +166,7 @@ public bool WaitForSearchComplete(Action search, TimeSpan timeout) } finally { - _packageManagerControl.PackageList.LoadItemsCompleted -= eventHandler; + _packageManagerControl.PackageList.ViewModel.LoadItemsCompleted -= eventHandler; } }