From 46488622a7a0921714b5a0df002007ac6c8e5438 Mon Sep 17 00:00:00 2001 From: Oren Novotny Date: Sat, 28 Apr 2018 15:48:51 -0400 Subject: [PATCH] New Package Chooser Dialog (#410) * Modify PackageChooserDialog The DataGrid for displaying the packages is replaced by a ListBox control. Another control (PackageDetailControl) is added to the right of the list to display detailed information of the package selected from the list. Additionally, the following UI changes have been made. * The search box is now showing a watermark and is placed in the top- left corner. * The "Close" button is redundant so it is now removed. * The nav buttons are moved to the footer region for better aesthetics. * Package source controls are moved to the top right corner. * The "Show pre-release" checkbox is moved closer to the search box. * The "Auto-load" checkbox is combined with the reload button as a dropdown button menu item. Some plumbing code changes are made to support the UI changes. * Add IconUrlToImageCacheConverter: This converter can be used to download an image given an URL. If there's no valid URL, a default icon is used instead. The images are cached to reduce download time. * Add NumberToStringConverter: This converter can be used to display download numbers that are larger than 1000 in kilos, megas, gigas, and teras unit so that it takes less space to display these them. * PackageInfo class now has the following additional fields. * Description * Tags * Summary * License URL * Project URL * Report Abuse URL * Icon URL * Rename PackageRowDetails to PackageDetailActionsControl: This control shows actions for a package in details view. The actions consist of opening, downloading, and viewing all versions. * Autoload pkgs upon source selection change When user select a source from the dropdown, the packages will be loaded automatically. The Reload button is removed as a result of this change. * Update relative paths to nav button images * PackageDetailActionsControl: Add default ctor The non-existing PackageVersionsHeaderStyle is also removed from the GridView. * Store PackageChooser UI labels in Resources.resx * Pkg Chooser: Improve package selection * After the packages have been loaded, the first package will be automatically selected so that the details view doesn't look empty. The detail view is also updated when the selection changes using the up/down arrow keys. * PackageChooserDialog.xaml.cs > CancelPendingRequestAndCloseDialog(): set the selected package field to null to prevents the package from being loaded into the main window after the package chooser is hidden. * Remove AutoLoadPackages setting This setting is no longer needed since the packages are auto-loaded when the window is loaded and source selection is changed. * PackageChooserViewModel - Code Cleanup * Group bound properties into one region * Rearrange declared helper (private) methods in the order they are invoked. * Remove AutoLoadPackages property. * Rename private fields to better express intent. * Hide the detail view when no pkg is selected * Use Humanizer for conversion * don't always copy * Show PrefixReserved indicator * use semantic verison * use SelectedPackage * avoid number parsing if value is already a number * Extract the detail to a different control --- Core/Packages/PackageInfo.cs | 10 +- PackageExplorer/App.xaml | 37 +- PackageExplorer/App.xaml.cs | 7 + PackageExplorer/Common/ErrorFloodGate.cs | 65 ++++ .../IconUrlToImageCacheConverter.cs | 134 +++++++ .../Converters/NumberToStringConverter.cs | 34 ++ PackageExplorer/NuGetPackageExplorer.csproj | 31 +- .../PackageChooser/PackageChooserDialog.xaml | 213 ++++++++++++ .../PackageChooserDialog.xaml.cs | 75 ++-- .../PackageDetailActionsControl.xaml} | 119 ++++--- .../PackageDetailActionsControl.xaml.cs | 33 ++ .../PackageChooser/PackageDetailControl.xaml | 102 ++++++ .../PackageDetailControl.xaml.cs | 23 ++ .../PackageChooser/PackageListItem.xaml | 59 ++++ .../PackageChooser/PackageListItem.xaml.cs | 28 ++ PackageExplorer/PackageChooserDialog.xaml | 262 -------------- PackageExplorer/PackageRowDetails.xaml.cs | 83 ----- PackageExplorer/Properties/Settings.settings | 3 - PackageExplorer/Resources.Designer.cs | 198 +++++++++++ PackageExplorer/Resources.resx | 66 ++++ PackageExplorer/Resources/Images.cs | 24 ++ .../Resources/default-package-icon.png | Bin 0 -> 13750 bytes .../Xaml/PrefixReservedIndicator.xaml | 14 + .../PackageChooser/PackageChooserViewModel.cs | 329 +++++++++--------- .../PackageChooser/PackageInfoViewModel.cs | 17 +- PackageViewModel/PackageViewModel.csproj | 15 + PackageViewModel/PackageViewModelFactory.cs | 14 +- 27 files changed, 1340 insertions(+), 655 deletions(-) create mode 100644 PackageExplorer/Common/ErrorFloodGate.cs create mode 100644 PackageExplorer/Converters/IconUrlToImageCacheConverter.cs create mode 100644 PackageExplorer/Converters/NumberToStringConverter.cs create mode 100644 PackageExplorer/PackageChooser/PackageChooserDialog.xaml rename PackageExplorer/{ => PackageChooser}/PackageChooserDialog.xaml.cs (76%) rename PackageExplorer/{PackageRowDetails.xaml => PackageChooser/PackageDetailActionsControl.xaml} (52%) create mode 100644 PackageExplorer/PackageChooser/PackageDetailActionsControl.xaml.cs create mode 100644 PackageExplorer/PackageChooser/PackageDetailControl.xaml create mode 100644 PackageExplorer/PackageChooser/PackageDetailControl.xaml.cs create mode 100644 PackageExplorer/PackageChooser/PackageListItem.xaml create mode 100644 PackageExplorer/PackageChooser/PackageListItem.xaml.cs delete mode 100644 PackageExplorer/PackageChooserDialog.xaml delete mode 100644 PackageExplorer/PackageRowDetails.xaml.cs create mode 100644 PackageExplorer/Resources/Images.cs create mode 100644 PackageExplorer/Resources/default-package-icon.png create mode 100644 PackageExplorer/Xaml/PrefixReservedIndicator.xaml diff --git a/Core/Packages/PackageInfo.cs b/Core/Packages/PackageInfo.cs index 87db333a0..55c15ffd4 100644 --- a/Core/Packages/PackageInfo.cs +++ b/Core/Packages/PackageInfo.cs @@ -17,10 +17,19 @@ public PackageInfo(PackageIdentity identity) public NuGetVersion SemanticVersion => Identity.Version; public string Version => SemanticVersion.ToFullString(); + public string Description { get; set; } + public string Summary { get; set; } public string Authors { get; set; } public int DownloadCount { get; set; } public DateTimeOffset? Published { get; set; } + public string IconUrl { get; set; } + public string LicenseUrl { get; set; } + public string ProjectUrl { get; set; } + public string Tags { get; set; } + public string ReportAbuseUrl { get; set; } + + public bool IsPrefixReserved { get; set; } public bool IsRemotePackage { get; set; } public bool IsUnlisted @@ -38,6 +47,5 @@ public bool IsPrerelease return SemanticVersion != null && SemanticVersion.IsPrerelease; } } - } } diff --git a/PackageExplorer/App.xaml b/PackageExplorer/App.xaml index 42a41570c..d2389f84b 100644 --- a/PackageExplorer/App.xaml +++ b/PackageExplorer/App.xaml @@ -1,9 +1,16 @@ - + + @@ -19,7 +26,9 @@ - + + + @@ -109,10 +118,10 @@ @@ -177,6 +186,26 @@ + + \ No newline at end of file diff --git a/PackageExplorer/App.xaml.cs b/PackageExplorer/App.xaml.cs index 3b8ae7837..57278657e 100644 --- a/PackageExplorer/App.xaml.cs +++ b/PackageExplorer/App.xaml.cs @@ -4,6 +4,7 @@ using System.IO; using System.Threading.Tasks; using System.Windows; +using System.Windows.Controls; using NuGet.Credentials; using NuGet.Protocol; using PackageExplorer.Properties; @@ -115,5 +116,11 @@ private void Application_Exit(object sender, ExitEventArgs e) { } } + + private void PackageIconImage_ImageFailed(object sender, ExceptionRoutedEventArgs e) + { + var image = sender as Image; + image.Source = Images.DefaultPackageIcon; + } } } diff --git a/PackageExplorer/Common/ErrorFloodGate.cs b/PackageExplorer/Common/ErrorFloodGate.cs new file mode 100644 index 000000000..7c74cc824 --- /dev/null +++ b/PackageExplorer/Common/ErrorFloodGate.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; + +namespace PackageExplorer +{ + public class ErrorFloodGate + { + private const double StopLoadingThreshold = 0.50; + private const int SlidingExpirationInMinutes = 60; + private const int MinFailuresCount = 5; + private const int SecondsInOneTick = 5; + private readonly DateTimeOffset _origin = DateTimeOffset.Now; + private readonly Queue _attempts = new Queue(); + private readonly Queue _failures = new Queue(); + + private DateTimeOffset _lastEvaluate = DateTimeOffset.Now; + + private bool _isOpen; + public bool IsOpen + { + get + { + if (GetTicks(_lastEvaluate) > 1) + { + var discardOlderThan1Hour = GetTicks(DateTimeOffset.Now.AddMinutes(-SlidingExpirationInMinutes)); + + ExpireOlderValues(_attempts, discardOlderThan1Hour); + ExpireOlderValues(_failures, discardOlderThan1Hour); + + var attemptsCount = _attempts.Count; + var failuresCount = _failures.Count; + _isOpen = attemptsCount > 0 && failuresCount > MinFailuresCount && ((double)failuresCount / attemptsCount) > StopLoadingThreshold; + _lastEvaluate = DateTimeOffset.Now; + } + return _isOpen; + } + } + + private void ExpireOlderValues(Queue q, int expirationOffsetInTicks) + { + while (q.Count > 0 && q.Peek() < expirationOffsetInTicks) + { + q.Dequeue(); + } + } + + public void ReportAttempt() + { + var ticks = GetTicks(_origin); + _attempts.Enqueue(ticks); + } + + public void ReportError() + { + int ticks = GetTicks(_origin); + _failures.Enqueue(ticks); + } + + // Ticks here are of 5sec long + private int GetTicks(DateTimeOffset origin) + { + return (int)((DateTimeOffset.Now - origin).TotalSeconds / SecondsInOneTick); + } + } +} diff --git a/PackageExplorer/Converters/IconUrlToImageCacheConverter.cs b/PackageExplorer/Converters/IconUrlToImageCacheConverter.cs new file mode 100644 index 000000000..cd5132f62 --- /dev/null +++ b/PackageExplorer/Converters/IconUrlToImageCacheConverter.cs @@ -0,0 +1,134 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Runtime.Caching; +using System.Windows.Data; +using System.Windows.Media.Imaging; + +namespace PackageExplorer +{ + public class IconUrlToImageCacheConverter : IValueConverter + { + private const int DecodePixelWidth = 32; + + private static readonly ObjectCache BitmapImageCache = MemoryCache.Default; + + private static readonly WebExceptionStatus[] FatalErrors = { + WebExceptionStatus.ConnectFailure, + WebExceptionStatus.RequestCanceled, + WebExceptionStatus.ConnectionClosed, + WebExceptionStatus.Timeout, + WebExceptionStatus.UnknownError + }; + + private static readonly System.Net.Cache.RequestCachePolicy RequestCacheIfAvailable = new System.Net.Cache.RequestCachePolicy(System.Net.Cache.RequestCacheLevel.CacheIfAvailable); + + private static readonly ErrorFloodGate ErrorFloodGate = new ErrorFloodGate(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var iconUrl = value as string; + var defaultPackageIcon = parameter as BitmapSource; + if (string.IsNullOrWhiteSpace(iconUrl)) + { + return null; + } + + if (BitmapImageCache.Get(iconUrl) is BitmapSource cachedBitmapImage) + { + return cachedBitmapImage; + } + + // Some people run on networks with internal NuGet feeds, but no access to the package images on the internet. + // This is meant to detect that kind of case, and stop spamming the network, so the app remains responsive. + if (ErrorFloodGate.IsOpen) + { + return defaultPackageIcon; + } + + var iconBitmapImage = new BitmapImage(); + iconBitmapImage.BeginInit(); + iconBitmapImage.UriSource = new Uri(iconUrl); + + // Default cache policy: Per MSDN, satisfies a request for a resource either by using the cached copy of the resource or by sending a request + // for the resource to the server. The action taken is determined by the current cache policy and the age of the content in the cache. + // This is the cache level that should be used by most applications. + iconBitmapImage.UriCachePolicy = RequestCacheIfAvailable; + + // Instead of scaling larger images and keeping larger image in memory, this makes it so we scale it down, and throw away the bigger image. + // Only need to set this on one dimension, to preserve aspect ratio + iconBitmapImage.DecodePixelWidth = DecodePixelWidth; + + iconBitmapImage.DecodeFailed += IconBitmapImage_DownloadOrDecodeFailed; + iconBitmapImage.DownloadFailed += IconBitmapImage_DownloadOrDecodeFailed; + iconBitmapImage.DownloadCompleted += IconBitmapImage_DownloadCompleted; + + try + { + iconBitmapImage.EndInit(); + } + // if the URL is a file: URI (which actually happened!), we'll get an exception. + // if the URL is a file: URI which is in an existing directory, but the file doesn't exist, we'll fail silently. + catch (Exception) + { + iconBitmapImage = null; + } + finally + { + // store this bitmapImage in the bitmap image cache, so that other occurances can reuse the BitmapImage + cachedBitmapImage = iconBitmapImage ?? defaultPackageIcon; + AddToCache(iconUrl, cachedBitmapImage); + + ErrorFloodGate.ReportAttempt(); + } + + return cachedBitmapImage; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + + private static void AddToCache(string iconUrl, BitmapSource iconBitmapImage) + { + var policy = new CacheItemPolicy + { + SlidingExpiration = TimeSpan.FromMinutes(10), + RemovedCallback = CacheEntryRemoved + }; + BitmapImageCache.Set(iconUrl, iconBitmapImage, policy); + } + + private static void CacheEntryRemoved(CacheEntryRemovedArguments arguments) + { + + } + + private void IconBitmapImage_DownloadCompleted(object sender, EventArgs e) + { + if (sender is BitmapImage bitmapImage && !bitmapImage.IsFrozen) + { + bitmapImage.Freeze(); + } + } + + private void IconBitmapImage_DownloadOrDecodeFailed(object sender, System.Windows.Media.ExceptionEventArgs e) + { + // Fix the bitmap image cache to have default package icon, if some other failure didn't already do that. + if (!(sender is BitmapImage bitmapImage)) return; + + var cachedBitmapImage = BitmapImageCache.Get(bitmapImage.UriSource.ToString()) as BitmapSource; + if (cachedBitmapImage != Images.DefaultPackageIcon) + { + AddToCache(bitmapImage.UriSource.ToString(), Images.DefaultPackageIcon); + + if (e.ErrorException is WebException webex && FatalErrors.Any(status => webex.Status == status)) + { + ErrorFloodGate.ReportError(); + } + } + } + } +} diff --git a/PackageExplorer/Converters/NumberToStringConverter.cs b/PackageExplorer/Converters/NumberToStringConverter.cs new file mode 100644 index 000000000..a253da58c --- /dev/null +++ b/PackageExplorer/Converters/NumberToStringConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using Humanizer; + +namespace PackageExplorer +{ + public class NumberToStringConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is int i) + { + return i.ToMetric(decimals: 1); + } + if (value is double dbl) + { + return dbl.ToMetric(decimals: 1); + } + if (value != null) + { + var number = double.Parse(value.ToString(), culture); + return number.ToMetric(decimals: 1); + } + + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/PackageExplorer/NuGetPackageExplorer.csproj b/PackageExplorer/NuGetPackageExplorer.csproj index 06faa27a9..01702a645 100644 --- a/PackageExplorer/NuGetPackageExplorer.csproj +++ b/PackageExplorer/NuGetPackageExplorer.csproj @@ -1,15 +1,15 @@  - + <_SdkLanguageName>CSharp - + net461 WinExe - NuGetPackageExplorer + NuGetPackageExplorer nupack.ico Properties\app.manifest NuGet Package Explorer @@ -17,28 +17,37 @@ false PackageExplorer true - + - - - + + + - - + + + - + + - + + + + + PreserveNewest + + + diff --git a/PackageExplorer/PackageChooser/PackageChooserDialog.xaml b/PackageExplorer/PackageChooser/PackageChooserDialog.xaml new file mode 100644 index 000000000..36e0052be --- /dev/null +++ b/PackageExplorer/PackageChooser/PackageChooserDialog.xaml @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -