Skip to content

Commit

Permalink
New Package Chooser Dialog (#410)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Oren Novotny committed Apr 28, 2018
1 parent 6f465ac commit 4648862
Show file tree
Hide file tree
Showing 27 changed files with 1,340 additions and 655 deletions.
10 changes: 9 additions & 1 deletion Core/Packages/PackageInfo.cs
Expand Up @@ -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
Expand All @@ -38,6 +47,5 @@ public bool IsPrerelease
return SemanticVersion != null && SemanticVersion.IsPrerelease;
}
}

}
}
37 changes: 33 additions & 4 deletions PackageExplorer/App.xaml
@@ -1,9 +1,16 @@
<Application x:Class="PackageExplorer.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:PresentationOptions="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:self="clr-namespace:PackageExplorer" Startup="Application_Startup" Exit="Application_Exit">
<Application x:Class="PackageExplorer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:PresentationOptions="http://schemas.microsoft.com/winfx/2006/xaml/presentation/options"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:self="clr-namespace:PackageExplorer"
Startup="Application_Startup"
Exit="Application_Exit">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Xaml/MenuItems.xaml" />
<ResourceDictionary Source="Xaml/Toolbar.xaml" />
<ResourceDictionary Source="Xaml/PrefixReservedIndicator.xaml" />
</ResourceDictionary.MergedDictionaries>

<self:IntegerToBooleanConverter x:Key="FontSizeToBoolConverter" />
Expand All @@ -19,7 +26,9 @@
<self:DateTimeOffsetHumanizeConverter x:Key="DateTimeOffsetHumanizeConverter" />
<self:DateTimeOffsetLongDateConverter x:Key="DateTimeOffsetLongDateConverter" />
<self:CertificateToSubjectConverter x:Key="CertificateToSubjectConverter" />

<self:IconUrlToImageCacheConverter x:Key="IconUrlToImageCacheConverter" />
<self:NumberToStringConverter x:Key="NumberToStringConverter" />

<ContextMenu x:Key="TextBoxContextMenu">
<self:GrayscaleMenuItem Command="Cut">
<self:GrayscaleMenuItem.Icon>
Expand Down Expand Up @@ -109,10 +118,10 @@

<!-- used on the package chooser dialog -->
<Style x:Key="ShowAllVersionsRunStyle" TargetType="{x:Type Run}">
<Setter Property="Text" Value="show all versions" />
<Setter Property="Text" Value="{x:Static self:Resources.PackageChooser_ActionShowAllVersions}" />
<Style.Triggers>
<DataTrigger Binding="{Binding ShowingAllVersions}" Value="True">
<Setter Property="Text" Value="hide all versions" />
<Setter Property="Text" Value="{x:Static self:Resources.PackageChooser_ActionHideAllVersions}" />
</DataTrigger>
</Style.Triggers>
</Style>
Expand Down Expand Up @@ -177,6 +186,26 @@
</Image.Source>
</Image>
</ControlTemplate>

<Style
x:Key="PackageIconImageStyle"
TargetType="{x:Type Image}">
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="Height" Value="32" />
<Setter Property="Width" Value="32" />
<Setter Property="Source">
<Setter.Value>
<Binding
Path="DataContext.LatestPackageInfo.IconUrl"
RelativeSource="{RelativeSource Self}"
Converter="{StaticResource IconUrlToImageCacheConverter}"
ConverterParameter="{x:Static self:Images.DefaultPackageIcon}"
TargetNullValue="{x:Static self:Images.DefaultPackageIcon}"
FallbackValue="{x:Static self:Images.DefaultPackageIcon}"/>
</Setter.Value>
</Setter>
<EventSetter Event="ImageFailed" Handler="PackageIconImage_ImageFailed"/>
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>
7 changes: 7 additions & 0 deletions PackageExplorer/App.xaml.cs
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
65 changes: 65 additions & 0 deletions 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<int> _attempts = new Queue<int>();
private readonly Queue<int> _failures = new Queue<int>();

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<int> 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);
}
}
}
134 changes: 134 additions & 0 deletions 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();
}
}
}
}
}
34 changes: 34 additions & 0 deletions 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();
}
}
}

0 comments on commit 4648862

Please sign in to comment.