Pārlūkot izejas kodu

Merge branch 'feature/progress' into develop

* feature/progress:
  Display 'Error:' in error field
  Turns out that syncthing uses 'dirs', not 'folders'
  Open file/folder when clicked
  Add download rate indication
  Correct formatting of BytesToHuman
  Fix display of speeds when connection info not available
  Internationalize FileTransfersTrayView
  Handle broken ItemFinishedEvent error field
  More work - almost there
  More work on styling the popup
  Functional, but ugly, implementation
  WIP playing around with progress popup
Antony Male 10 gadi atpakaļ
vecāks
revīzija
5c8370c54b
26 mainītis faili ar 1018 papildinājumiem un 85 dzēšanām
  1. 6 1
      src/SyncTrayzor/App.xaml
  2. 2 2
      src/SyncTrayzor/Bootstrapper.cs
  3. 61 0
      src/SyncTrayzor/Design/DummyFileTransfersTrayViewModel.cs
  4. 16 0
      src/SyncTrayzor/Design/ViewModelLocator.cs
  5. 4 2
      src/SyncTrayzor/NotifyIcon/NotifyIconManager.cs
  6. 4 1
      src/SyncTrayzor/NotifyIcon/NotifyIconViewModel.cs
  7. 12 2
      src/SyncTrayzor/NotifyIcon/TaskbarIconResources.xaml
  8. 107 0
      src/SyncTrayzor/Pages/FileTransfersTrayView.xaml
  9. 221 0
      src/SyncTrayzor/Pages/FileTransfersTrayViewModel.cs
  10. 172 1
      src/SyncTrayzor/Properties/Strings/Resources.Designer.cs
  11. 70 0
      src/SyncTrayzor/Properties/Strings/Resources.resx
  12. 3 15
      src/SyncTrayzor/SyncThing/ApiClient/DownloadProgressEvent.cs
  13. 19 18
      src/SyncTrayzor/SyncThing/ApiClient/ItemFinishedEvent.cs
  14. 3 2
      src/SyncTrayzor/SyncThing/ApiClient/ItemStartedEvent.cs
  15. 14 0
      src/SyncTrayzor/SyncThing/EventWatcher/ItemChangedActionType.cs
  16. 14 0
      src/SyncTrayzor/SyncThing/EventWatcher/ItemChangedItemType.cs
  17. 23 0
      src/SyncTrayzor/SyncThing/EventWatcher/ItemFinishedEventArgs.cs
  18. 21 0
      src/SyncTrayzor/SyncThing/EventWatcher/ItemStartedEventArgs.cs
  19. 28 12
      src/SyncTrayzor/SyncThing/EventWatcher/SyncThingEventWatcher.cs
  20. 1 2
      src/SyncTrayzor/SyncThing/SyncThingManager.cs
  21. 30 5
      src/SyncTrayzor/SyncThing/TransferHistory/FileTransfer.cs
  22. 26 18
      src/SyncTrayzor/SyncThing/TransferHistory/SyncThingTransferHistory.cs
  23. 14 0
      src/SyncTrayzor/SyncTrayzor.csproj
  24. 34 4
      src/SyncTrayzor/Utils/FormatUtils.cs
  25. 64 0
      src/SyncTrayzor/Utils/ShellTools.cs
  26. 49 0
      src/SyncTrayzor/Xaml/PopupConductorBehaviour.cs

+ 6 - 1
src/SyncTrayzor/App.xaml

@@ -2,7 +2,8 @@
              xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:s="https://github.com/canton7/Stylet"
-             xmlns:local="clr-namespace:SyncTrayzor">
+             xmlns:local="clr-namespace:SyncTrayzor"
+             xmlns:design="clr-namespace:SyncTrayzor.Design">
     <Application.Resources>
         <s:ApplicationLoader>
             <s:ApplicationLoader.Bootstrapper>
@@ -12,6 +13,10 @@
             <s:ApplicationLoader.MergedDictionaries>
                 <ResourceDictionary Source="NotifyIcon/TaskbarIconResources.xaml"/>
                 <ResourceDictionary Source="Xaml/Resources.xaml"/>
+                
+                <ResourceDictionary>
+                    <design:ViewModelLocator x:Key="ViewModelLocator"/>
+                </ResourceDictionary>
             </s:ApplicationLoader.MergedDictionaries>
         </s:ApplicationLoader>
     </Application.Resources>

+ 2 - 2
src/SyncTrayzor/Bootstrapper.cs

@@ -129,8 +129,6 @@ namespace SyncTrayzor
                 { MessageBoxResult.OK, Localizer.Translate("Generic_Dialog_OK") },
                 { MessageBoxResult.Yes, Localizer.Translate("Generic_Dialog_Yes") },
             };
-
-            this.Container.Get<IApplicationState>().ApplicationStarted();
         }
 
         protected override void Launch()
@@ -143,6 +141,8 @@ namespace SyncTrayzor
 
         protected override void OnLaunch()
         {
+            this.Container.Get<IApplicationState>().ApplicationStarted();
+
             var config = this.Container.Get<IConfigurationProvider>().Load();
             if (config.StartSyncthingAutomatically && !this.Args.Contains("-noautostart"))
                 ((ShellViewModel)this.RootViewModel).Start();

+ 61 - 0
src/SyncTrayzor/Design/DummyFileTransfersTrayViewModel.cs

@@ -0,0 +1,61 @@
+using Stylet;
+using SyncTrayzor.Pages;
+using SyncTrayzor.SyncThing.EventWatcher;
+using SyncTrayzor.SyncThing.TransferHistory;
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SyncTrayzor.Design
+{
+    public class DummyFileTransfersTrayViewModel
+    {
+        public BindableCollection<FileTransferViewModel> CompletedTransfers { get; private set; }
+        public BindableCollection<FileTransferViewModel> InProgressTransfers { get; private set; }
+
+        public bool HasCompletedTransfers
+        {
+            get { return this.CompletedTransfers.Count > 0; }
+        }
+        public bool HasInProgressTransfers
+        {
+            get { return this.InProgressTransfers.Count > 0; }
+        }
+
+        public string InConnectionRate { get; private set; }
+        public string OutConnectionRate { get; private set; }
+
+        public bool AnyTransfers { get; private set; }
+
+        public DummyFileTransfersTrayViewModel()
+        {
+            this.CompletedTransfers = new BindableCollection<FileTransferViewModel>();
+            this.InProgressTransfers = new BindableCollection<FileTransferViewModel>();
+
+            var completedFileTransfer1 = new FileTransfer("folder", "path.pdf", ItemChangedItemType.File, ItemChangedActionType.Update);
+            completedFileTransfer1.SetComplete(null);
+
+            var completedFileTransfer2 = new FileTransfer("folder", "a really very long path that's far too long to sit on the page.h", ItemChangedItemType.File, ItemChangedActionType.Delete);
+            completedFileTransfer2.SetComplete("Something went very wrong");
+
+            //this.CompletedTransfers.Add(new FileTransferViewModel(completedFileTransfer1));
+            //this.CompletedTransfers.Add(new FileTransferViewModel(completedFileTransfer2));
+
+            var inProgressTransfer1 = new FileTransfer("folder", "path.txt", ItemChangedItemType.File, ItemChangedActionType.Update);
+            inProgressTransfer1.SetDownloadProgress(5*1024*1024, 100*1024*1024);
+
+            var inProgressTransfer2 = new FileTransfer("folder", "path", ItemChangedItemType.Folder, ItemChangedActionType.Update);
+
+            this.InProgressTransfers.Add(new FileTransferViewModel(inProgressTransfer1));
+            this.InProgressTransfers.Add(new FileTransferViewModel(inProgressTransfer2));
+
+            this.InConnectionRate = "1.2MB";
+            this.OutConnectionRate = "0.0MB";
+
+            this.AnyTransfers = true;
+        }
+    }
+}

+ 16 - 0
src/SyncTrayzor/Design/ViewModelLocator.cs

@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SyncTrayzor.Design
+{
+    public class ViewModelLocator
+    {
+        public DummyFileTransfersTrayViewModel FileTransfersTrayViewModel
+        {
+            get { return new DummyFileTransfersTrayViewModel(); }
+        }
+    }
+}

+ 4 - 2
src/SyncTrayzor/NotifyIcon/NotifyIconManager.cs

@@ -78,11 +78,12 @@ namespace SyncTrayzor.NotifyIcon
             this.syncThingManager = syncThingManager;
 
             this.taskbarIcon = (TaskbarIcon)this.application.FindResource("TaskbarIcon");
-            this.viewManager.BindViewToModel(this.taskbarIcon, this.viewModel);
+            // Need to hold off until after the application is started, otherwise the ViewManager won't be set
+            this.application.Startup += (o, e) => this.viewManager.BindViewToModel(this.taskbarIcon, this.viewModel);
 
             this.applicationWindowState.RootWindowActivated += this.RootViewModelActivated;
             this.applicationWindowState.RootWindowDeactivated += this.RootViewModelDeactivated;
-            this.applicationWindowState.RootWindowClosed += this.RootViewModelClosed; 
+            this.applicationWindowState.RootWindowClosed += this.RootViewModelClosed;
 
             this.viewModel.WindowOpenRequested += (o, e) =>
             {
@@ -140,6 +141,7 @@ namespace SyncTrayzor.NotifyIcon
 
             var view = this.viewManager.CreateViewForModel(viewModel);
             this.taskbarIcon.ShowCustomBalloon(view, System.Windows.Controls.Primitives.PopupAnimation.Slide, timeout);
+            this.taskbarIcon.CustomBalloon.StaysOpen = false;
             this.viewManager.BindViewToModel(view, viewModel); // Re-assign DataContext, after NotifyIcon overwrote it ><
 
             this.balloonTcs = new TaskCompletionSource<bool?>();

+ 4 - 1
src/SyncTrayzor/NotifyIcon/NotifyIconViewModel.cs

@@ -23,6 +23,7 @@ namespace SyncTrayzor.NotifyIcon
         public bool Visible { get; set; }
         public bool MainWindowVisible { get; set; }
         public BindableCollection<FolderViewModel> Folders { get; private set; }
+        public FileTransfersTrayViewModel FileTransfersViewModel { get; private set; }
 
         public event EventHandler WindowOpenRequested;
         public event EventHandler WindowCloseRequested;
@@ -41,12 +42,14 @@ namespace SyncTrayzor.NotifyIcon
             IWindowManager windowManager,
             ISyncThingManager syncThingManager,
             Func<SettingsViewModel> settingsViewModelFactory,
-            IProcessStartProvider processStartProvider)
+            IProcessStartProvider processStartProvider,
+            FileTransfersTrayViewModel fileTransfersViewModel)
         {
             this.windowManager = windowManager;
             this.syncThingManager = syncThingManager;
             this.settingsViewModelFactory = settingsViewModelFactory;
             this.processStartProvider = processStartProvider;
+            this.FileTransfersViewModel = fileTransfersViewModel;
 
             this.syncThingManager.StateChanged += (o, e) =>
             {

+ 12 - 2
src/SyncTrayzor/NotifyIcon/TaskbarIconResources.xaml

@@ -4,7 +4,8 @@
                     xmlns:l="clr-namespace:SyncTrayzor.Localization"
                     xmlns:ni="clr-namespace:SyncTrayzor.NotifyIcon"
                     xmlns:tb="http://www.hardcodet.net/taskbar"
-                    xmlns:xaml="clr-namespace:SyncTrayzor.Xaml">
+                    xmlns:xaml="clr-namespace:SyncTrayzor.Xaml"
+                    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity">
 
     <BitmapImage x:Key="TaskbarStoppedIcon" UriSource="pack://application:,,,/Icons/stopped.ico"/>
     <BitmapImage x:Key="TaskbarSyncing1Icon" UriSource="pack://application:,,,/Icons/default_tray.ico"/>
@@ -12,10 +13,19 @@
     <BitmapImage x:Key="TaskbarSyncing3Icon" UriSource="pack://application:,,,/Icons/syncing_3.ico"/>
     <BitmapImage x:Key="TaskbarSyncing4Icon" UriSource="pack://application:,,,/Icons/syncing_4.ico"/>
 
-    <tb:TaskbarIcon x:Key="TaskbarIcon"
+    <tb:TaskbarIcon x:Key="TaskbarIcon" x:Name="TaskbarIcon"
                     Visibility="{Binding Visible, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"
                     DoubleClickCommand="{s:Action DoubleClick}"
                     MenuActivation="RightClick">
+        <tb:TaskbarIcon.TrayPopup>
+            <Popup StaysOpen="False" Placement="Relative" HorizontalOffset="-150" VerticalOffset="100" Width="300" PlacementTarget="{Binding ElementName=TaskbarIcon}" AllowsTransparency="True">
+                <i:Interaction.Behaviors>
+                    <xaml:PopupConductorBehaviour DataContext="{Binding FileTransfersViewModel}"/>
+                </i:Interaction.Behaviors>
+                
+                <ContentControl s:View.Model="{Binding FileTransfersViewModel}"/>
+            </Popup>
+        </tb:TaskbarIcon.TrayPopup>
         <tb:TaskbarIcon.Resources>
             <Storyboard x:Key="IconAnimation">
                 <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(ni:NotifyIconResolutionUtilities.IconSource)" Duration="0:0:2" RepeatBehavior="Forever">

+ 107 - 0
src/SyncTrayzor/Pages/FileTransfersTrayView.xaml

@@ -0,0 +1,107 @@
+<UserControl x:Class="SyncTrayzor.Pages.FileTransfersTrayView"
+             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:s="https://github.com/canton7/Stylet"
+             xmlns:l="clr-namespace:SyncTrayzor.Localization"
+             xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
+             mc:Ignorable="d" 
+             x:Name="RootObject"
+             Height="300" d:DesignWidth="300"
+             d:DataContext="{Binding Source={StaticResource ViewModelLocator}, Path=FileTransfersTrayViewModel}">
+    <UserControl.Resources>
+        <Style x:Key="SectionTitleStyle" TargetType="TextBlock">
+            <Setter Property="Foreground" Value="DimGray"/>
+            <Setter Property="Margin" Value="5,3,0,3"/>
+            <Setter Property="FontSize" Value="14"/>
+        </Style>
+        <Style x:Key="IconStyle" TargetType="Image">
+            <Setter Property="Width" Value="30"/>
+            <Setter Property="Height" Value="30"/>
+            <Setter Property="Margin" Value="0,0,10,0"/>
+        </Style>
+        <Style x:Key="ItemBorderStyle" TargetType="Border">
+            <Setter Property="BorderThickness" Value="0,0,0,1"/>
+            <Setter Property="BorderBrush" Value="LightGray"/>
+        </Style>
+    </UserControl.Resources>
+    <Border CornerRadius="6" BorderThickness="2" BorderBrush="Gray" Background="White">
+        <DockPanel>
+            <Border DockPanel.Dock="Top" BorderBrush="Gray" BorderThickness="0,0,0,1">
+                <DockPanel Margin="5,8,5,0" LastChildFill="False">
+                    <TextBlock DockPanel.Dock="Left" Height="25" FontSize="15" FontWeight="Bold">SyncTrayzor</TextBlock>
+                    <TextBlock DockPanel.Dock="Right" VerticalAlignment="Center"
+                               Text="{l:Loc FileTransfersTrayView_OutConnectionRate, ValueBinding={Binding OutConnectionRate}}"/>
+                    <TextBlock DockPanel.Dock="Right" VerticalAlignment="Center" Margin="0,0,10,0"
+                               Text="{l:Loc FileTransfersTrayView_InConnectionRate, ValueBinding={Binding InConnectionRate}}"/>
+                </DockPanel>
+            </Border>
+            
+            <Grid>
+                <TextBlock DockPanel.Dock="Top" HorizontalAlignment="Center" VerticalAlignment="Center"
+                       Visibility="{Binding AnyTransfers, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"
+                           Text="{l:Loc FileTransfersTrayView_NothingToShow}"/>
+
+                <ScrollViewer VerticalScrollBarVisibility="Auto">
+                    <DockPanel LastChildFill="False">
+                        <Border DockPanel.Dock="Top" BorderBrush="LightGray" BorderThickness="0,0,0,1"
+                            Visibility="{Binding HasInProgressTransfers, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
+                            <TextBlock Style="{StaticResource SectionTitleStyle}" Text="{l:Loc FileTranfersTrayView_Header_Downloading}"/>
+                        </Border>
+                        <ItemsControl DockPanel.Dock="Top" ItemsSource="{Binding InProgressTransfers}">
+                            <ItemsControl.ItemTemplate>
+                                <DataTemplate>
+                                    <Border Style="{StaticResource ItemBorderStyle}">
+                                        <DockPanel Margin="5">
+                                            <Image DockPanel.Dock="Left" Style="{StaticResource IconStyle}"
+                                               Source="{Binding Icon, Converter={x:Static s:IconToBitmapSourceConverter.Instance}}"/>
+                                            <TextBlock DockPanel.Dock="Top" Text="{Binding Path}" TextTrimming="CharacterEllipsis"/>
+                                            <TextBlock DockPanel.Dock="Top" Foreground="DimGray" Text="{Binding ProgressString}"/>
+                                            <ProgressBar DockPanel.Dock="Top" Margin="0,4,0,0" Minimum="0" Maximum="100" Height="10"
+                                                 Value="{Binding ProgressPercent}" IsIndeterminate="{Binding IsStarting}"/>
+                                        </DockPanel>
+                                    </Border>
+                                </DataTemplate>
+                            </ItemsControl.ItemTemplate>
+                        </ItemsControl>
+
+                        <Border DockPanel.Dock="Top" BorderBrush="LightGray" BorderThickness="0,0,0,1"
+                            Visibility="{Binding HasCompletedTransfers, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
+                            <TextBlock Style="{StaticResource SectionTitleStyle}" Text="{l:Loc FileTransfersTrayView_Header_RecentlyUpdated}"/>
+                        </Border>
+                        <ItemsControl DockPanel.Dock="Top" ItemsSource="{Binding CompletedTransfers}">
+                            <ItemsControl.ItemTemplate>
+                                <DataTemplate>
+                                    <Border Style="{StaticResource ItemBorderStyle}">
+                                        <DockPanel Margin="5">
+                                            <!-- Use an InvokeCommandAction so we can pass a parameter -->
+                                            <i:Interaction.Triggers>
+                                                <i:EventTrigger EventName="MouseLeftButtonUp">
+                                                    <i:InvokeCommandAction s:View.ActionTarget="{Binding DataContext, Source={x:Reference RootObject}}"
+                                                        Command="{s:Action ItemClicked}" CommandParameter="{Binding}"/>
+                                                </i:EventTrigger>
+                                            </i:Interaction.Triggers>
+                                            <TextBlock DockPanel.Dock="Bottom" Margin="5,5,0,0" Foreground="Red" FontWeight="Bold"
+                                                   TextWrapping="Wrap"
+                                                   Visibility="{Binding Error, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"
+                                                   Text="{l:Loc FileTransfersTrayView_Error, ValueBinding={Binding Error}}"/>
+                                            <Image DockPanel.Dock="Left" Style="{StaticResource IconStyle}"
+                                               Source="{Binding Icon, Converter={x:Static s:IconToBitmapSourceConverter.Instance}}"/>
+                                            <TextBlock DockPanel.Dock="Top" Text="{Binding Path}" TextTrimming="CharacterEllipsis"/>
+                                            <StackPanel DockPanel.Dock="Top" Orientation="Horizontal">
+                                                <TextBlock Foreground="DimGray" Text="{Binding CompletedTimeAgo}"/>
+                                                <TextBlock Foreground="DimGray" Margin="5,0,0,0" Text="{l:Loc FileTransfersTrayView_Deleted}"
+                                                           Visibility="{Binding WasDeleted, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"/>
+                                            </StackPanel>
+                                        </DockPanel>
+                                    </Border>
+                                </DataTemplate>
+                            </ItemsControl.ItemTemplate>
+                        </ItemsControl>
+                    </DockPanel>
+                </ScrollViewer>
+            </Grid>
+        </DockPanel>
+    </Border>
+</UserControl>

+ 221 - 0
src/SyncTrayzor/Pages/FileTransfersTrayViewModel.cs

@@ -0,0 +1,221 @@
+using Pri.LongPath;
+using Stylet;
+using SyncTrayzor.Properties.Strings;
+using SyncTrayzor.Services;
+using SyncTrayzor.SyncThing;
+using SyncTrayzor.SyncThing.EventWatcher;
+using SyncTrayzor.SyncThing.TransferHistory;
+using SyncTrayzor.Utils;
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows.Threading;
+
+namespace SyncTrayzor.Pages
+{
+    public class FileTransferViewModel : PropertyChangedBase
+    {
+        public readonly FileTransfer FileTransfer;
+        private readonly DispatcherTimer completedTimeAgoUpdateTimer;
+
+        public string Path { get; private set; }
+        public Icon Icon { get; private set; }
+        public string Error { get; private set; }
+        public bool WasDeleted { get; private set; }
+        
+        public string CompletedTimeAgo
+        {
+            get
+            {
+                if (this.FileTransfer.FinishedUtc.HasValue)
+                    return FormatUtils.TimeSpanToTimeAgo(DateTime.UtcNow - this.FileTransfer.FinishedUtc.Value);
+                else
+                    return null;
+            }
+        }
+
+        public string ProgressString { get; private set; }
+        public bool IsStarting { get; private set; }
+        public float ProgressPercent { get; private set; }
+
+        public FileTransferViewModel(FileTransfer fileTransfer)
+        {
+            this.completedTimeAgoUpdateTimer = new DispatcherTimer()
+            {
+                Interval = TimeSpan.FromMinutes(1),
+            };
+            this.completedTimeAgoUpdateTimer.Tick += (o, e) => this.NotifyOfPropertyChange(() => this.CompletedTimeAgo);
+            this.completedTimeAgoUpdateTimer.Start();
+
+            this.FileTransfer = fileTransfer;
+            this.Path = Pri.LongPath.Path.GetFileName(this.FileTransfer.Path);
+            this.Icon = ShellTools.GetIcon(this.FileTransfer.Path, this.FileTransfer.ItemType == SyncThing.EventWatcher.ItemChangedItemType.File);
+            this.WasDeleted = this.FileTransfer.ActionType == SyncThing.EventWatcher.ItemChangedActionType.Delete;
+
+            this.UpdateState();
+        }
+
+        public void UpdateState()
+        {
+            switch (this.FileTransfer.Status)
+            {
+                case FileTransferStatus.Started:
+                    this.ProgressString = Resources.FileTransfersTrayView_Starting;
+                    this.IsStarting = true;
+                    this.ProgressPercent = 0;
+                    break;
+
+                case FileTransferStatus.InProgress:
+                    if (this.FileTransfer.DownloadBytesPerSecond.HasValue)
+                    {
+                        this.ProgressString = String.Format(Resources.FileTransfersTrayView_Downloading_RateKnown,
+                            FormatUtils.BytesToHuman(this.FileTransfer.BytesTransferred),
+                            FormatUtils.BytesToHuman(this.FileTransfer.TotalBytes),
+                            FormatUtils.BytesToHuman(this.FileTransfer.DownloadBytesPerSecond.Value, 1));
+                    }
+                    else
+                    {
+                            this.ProgressString = String.Format(Resources.FileTransfersTrayView_Downloading_RateUnknown,
+                            FormatUtils.BytesToHuman(this.FileTransfer.BytesTransferred),
+                            FormatUtils.BytesToHuman(this.FileTransfer.TotalBytes));
+                    }
+                    
+                    this.IsStarting = false;
+                    this.ProgressPercent = ((float)this.FileTransfer.BytesTransferred / (float)this.FileTransfer.TotalBytes) * 100;
+                    break;
+            }
+
+            this.Error = this.FileTransfer.Error;
+        }
+    }
+
+    public class FileTransfersTrayViewModel : Screen
+    {
+        private readonly ISyncThingManager syncThingManager;
+        private readonly IProcessStartProvider processStartProvider;
+
+        public BindableCollection<FileTransferViewModel> CompletedTransfers { get; private set; }
+        public BindableCollection<FileTransferViewModel> InProgressTransfers { get; private set; }
+
+        public bool HasCompletedTransfers
+        {
+            get { return this.CompletedTransfers.Count > 0; }
+        }
+        public bool HasInProgressTransfers
+        {
+            get { return this.InProgressTransfers.Count > 0; }
+        }
+
+        public string InConnectionRate { get; private set; }
+        public string OutConnectionRate { get; private set; }
+
+        public bool AnyTransfers
+        {
+            get { return this.HasCompletedTransfers || this.HasInProgressTransfers; }
+        }
+
+        public FileTransfersTrayViewModel(ISyncThingManager syncThingManager, IProcessStartProvider processStartProvider)
+        {
+            this.syncThingManager = syncThingManager;
+            this.processStartProvider = processStartProvider;
+
+            this.CompletedTransfers = new BindableCollection<FileTransferViewModel>();
+            this.InProgressTransfers = new BindableCollection<FileTransferViewModel>();
+
+            this.CompletedTransfers.CollectionChanged += (o, e) => { this.NotifyOfPropertyChange(() => this.HasCompletedTransfers); this.NotifyOfPropertyChange(() => this.AnyTransfers); };
+            this.InProgressTransfers.CollectionChanged += (o, e) => { this.NotifyOfPropertyChange(() => this.HasInProgressTransfers); this.NotifyOfPropertyChange(() => this.AnyTransfers); };
+        }
+
+        protected override void OnActivate()
+        {
+            foreach (var completedTransfer in this.syncThingManager.TransferHistory.CompletedTransfers.Take(10).Reverse())
+            {
+                this.CompletedTransfers.Add(new FileTransferViewModel(completedTransfer));
+            }
+
+            foreach (var inProgressTranser in this.syncThingManager.TransferHistory.InProgressTransfers.Reverse())
+            {
+                this.InProgressTransfers.Add(new FileTransferViewModel(inProgressTranser));
+            }
+
+            this.syncThingManager.TransferHistory.TransferStarted += this.TransferStarted;
+            this.syncThingManager.TransferHistory.TransferCompleted += this.TransferCompleted;
+            this.syncThingManager.TransferHistory.TransferStateChanged += this.TransferStateChanged;
+
+            this.UpdateConnectionStats(this.syncThingManager.TotalConnectionStats);
+
+            this.syncThingManager.TotalConnectionStatsChanged += this.TotalConnectionStatsChanged;
+        }
+
+        protected override void OnDeactivate()
+        {
+            this.syncThingManager.TransferHistory.TransferStarted -= this.TransferStarted;
+            this.syncThingManager.TransferHistory.TransferCompleted -= this.TransferCompleted;
+            this.syncThingManager.TransferHistory.TransferStateChanged -= this.TransferStateChanged;
+
+            this.syncThingManager.TotalConnectionStatsChanged -= this.TotalConnectionStatsChanged;
+
+            this.CompletedTransfers.Clear();
+            this.InProgressTransfers.Clear();
+        }
+
+        private void TransferStarted(object sender, FileTransferChangedEventArgs e)
+        {
+            this.InProgressTransfers.Insert(0, new FileTransferViewModel(e.FileTransfer));
+        }
+
+        private void TransferCompleted(object sender, FileTransferChangedEventArgs e)
+        {
+            var transferVm = this.InProgressTransfers.First(x => x.FileTransfer == e.FileTransfer);
+            this.InProgressTransfers.Remove(transferVm);
+            this.CompletedTransfers.Insert(0, transferVm);
+            transferVm.UpdateState();
+        }
+
+        private void TransferStateChanged(object sender, FileTransferChangedEventArgs e)
+        {
+            var transferVm = this.InProgressTransfers.FirstOrDefault(x => x.FileTransfer == e.FileTransfer);
+            if (transferVm != null)
+                transferVm.UpdateState();
+        }
+
+        private void TotalConnectionStatsChanged(object sender, ConnectionStatsChangedEventArgs e)
+        {
+            this.UpdateConnectionStats(e.TotalConnectionStats);
+        }
+
+        private void UpdateConnectionStats(SyncThingConnectionStats connectionStats)
+        {
+            if (connectionStats == null)
+            {
+                this.InConnectionRate = "0.0B";
+                this.OutConnectionRate = "0.0B";
+            }
+            else
+            {
+                this.InConnectionRate = FormatUtils.BytesToHuman(connectionStats.InBytesPerSecond, 1);
+                this.OutConnectionRate = FormatUtils.BytesToHuman(connectionStats.OutBytesPerSecond, 1);
+            }
+        }
+
+        public void ItemClicked(FileTransferViewModel fileTransferVm)
+        {
+            var fileTransfer = fileTransferVm.FileTransfer;
+            Folder folder;
+            if (!this.syncThingManager.Folders.TryFetchById(fileTransfer.FolderId, out folder))
+                return; // Huh? Nothing we can do about it...
+
+            // Not sure of the best way to deal with deletions yet...
+            if (fileTransfer.ActionType == ItemChangedActionType.Update)
+            {
+                if (fileTransfer.ItemType == ItemChangedItemType.File)
+                    this.processStartProvider.StartDetached("explorer.exe", String.Format("/select, \"{0}\"", Path.Combine(folder.Path, fileTransfer.Path)));
+                else
+                    this.processStartProvider.StartDetached("explorer.exe", Path.Combine(folder.Path, fileTransfer.Path));
+            }
+        }
+    }
+}

+ 172 - 1
src/SyncTrayzor/Properties/Strings/Resources.Designer.cs

@@ -1,7 +1,7 @@
 //------------------------------------------------------------------------------
 // <auto-generated>
 //     This code was generated by a tool.
-//     Runtime Version:4.0.30319.0
+//     Runtime Version:4.0.30319.34209
 //
 //     Changes to this file may cause incorrect behavior and will be lost if
 //     the code is regenerated.
@@ -212,6 +212,96 @@ namespace SyncTrayzor.Properties.Strings {
             }
         }
         
+        /// <summary>
+        ///   Looks up a localized string similar to Downloading.
+        /// </summary>
+        public static string FileTranfersTrayView_Header_Downloading {
+            get {
+                return ResourceManager.GetString("FileTranfersTrayView_Header_Downloading", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to (deleted).
+        /// </summary>
+        public static string FileTransfersTrayView_Deleted {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_Deleted", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to Downloading {0}/{1} ({2}/s).
+        /// </summary>
+        public static string FileTransfersTrayView_Downloading_RateKnown {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_Downloading_RateKnown", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to Downloading {0}/{1}.
+        /// </summary>
+        public static string FileTransfersTrayView_Downloading_RateUnknown {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_Downloading_RateUnknown", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to Error: {0}.
+        /// </summary>
+        public static string FileTransfersTrayView_Error {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_Error", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to Recently Updated.
+        /// </summary>
+        public static string FileTransfersTrayView_Header_RecentlyUpdated {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_Header_RecentlyUpdated", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to In: {0}/s.
+        /// </summary>
+        public static string FileTransfersTrayView_InConnectionRate {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_InConnectionRate", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to No recent file transfers.
+        /// </summary>
+        public static string FileTransfersTrayView_NothingToShow {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_NothingToShow", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to Out: {0}/s.
+        /// </summary>
+        public static string FileTransfersTrayView_OutConnectionRate {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_OutConnectionRate", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to Starting....
+        /// </summary>
+        public static string FileTransfersTrayView_Starting {
+            get {
+                return ResourceManager.GetString("FileTransfersTrayView_Starting", resourceCulture);
+            }
+        }
+        
         /// <summary>
         ///   Looks up a localized string similar to _Copy.
         /// </summary>
@@ -969,6 +1059,87 @@ namespace SyncTrayzor.Properties.Strings {
             }
         }
         
+        /// <summary>
+        ///   Looks up a localized string similar to {0} days ago.
+        /// </summary>
+        public static string TimeAgo_Days_Plural {
+            get {
+                return ResourceManager.GetString("TimeAgo_Days_Plural", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to 1 day ago.
+        /// </summary>
+        public static string TimeAgo_Days_Singular {
+            get {
+                return ResourceManager.GetString("TimeAgo_Days_Singular", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to {0} hours ago.
+        /// </summary>
+        public static string TimeAgo_Hours_Plural {
+            get {
+                return ResourceManager.GetString("TimeAgo_Hours_Plural", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to 1 hour ago.
+        /// </summary>
+        public static string TimeAgo_Hours_Singular {
+            get {
+                return ResourceManager.GetString("TimeAgo_Hours_Singular", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to Just now.
+        /// </summary>
+        public static string TimeAgo_JustNow {
+            get {
+                return ResourceManager.GetString("TimeAgo_JustNow", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to {0} minutes ago.
+        /// </summary>
+        public static string TimeAgo_Minutes_Plural {
+            get {
+                return ResourceManager.GetString("TimeAgo_Minutes_Plural", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to 1 minute ago.
+        /// </summary>
+        public static string TimeAgo_Minutes_Singular {
+            get {
+                return ResourceManager.GetString("TimeAgo_Minutes_Singular", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to {0} years ago.
+        /// </summary>
+        public static string TimeAgo_Years_Plural {
+            get {
+                return ResourceManager.GetString("TimeAgo_Years_Plural", resourceCulture);
+            }
+        }
+        
+        /// <summary>
+        ///   Looks up a localized string similar to 1 year ago.
+        /// </summary>
+        public static string TimeAgo_Years_Singular {
+            get {
+                return ResourceManager.GetString("TimeAgo_Years_Singular", resourceCulture);
+            }
+        }
+        
         /// <summary>
         ///   Looks up a localized string similar to &lt;LANGUAGE&gt; translation by &lt;YOUR NAME&gt;.
         /// </summary>

+ 70 - 0
src/SyncTrayzor/Properties/Strings/Resources.resx

@@ -581,4 +581,74 @@ SyncTrayzor is going to have to close. Sorry about that</value>
   <data name="Generic_Paste" xml:space="preserve">
     <value>_Paste</value>
   </data>
+  <data name="FileTranfersTrayView_Header_Downloading" xml:space="preserve">
+    <value>Downloading</value>
+    <comment>Header for section containing downloading files</comment>
+  </data>
+  <data name="FileTransfersTrayView_Deleted" xml:space="preserve">
+    <value>(deleted)</value>
+    <comment>Show after the 'x minutes ago' text if the update was a deletion</comment>
+  </data>
+  <data name="FileTransfersTrayView_Downloading_RateUnknown" xml:space="preserve">
+    <value>Downloading {0}/{1}</value>
+    <comment>Displayed when a large file is downloaded
+{0}: amount downloaded so far (inserted as e.g. 4KB)
+{1}: The total amount to download</comment>
+  </data>
+  <data name="FileTransfersTrayView_Header_RecentlyUpdated" xml:space="preserve">
+    <value>Recently Updated</value>
+    <comment>Header for section containing files that were recently downloaded or deleted</comment>
+  </data>
+  <data name="FileTransfersTrayView_InConnectionRate" xml:space="preserve">
+    <value>In: {0}/s</value>
+    <comment>{0}: a value such as '1.2kb'</comment>
+  </data>
+  <data name="FileTransfersTrayView_NothingToShow" xml:space="preserve">
+    <value>No recent file transfers</value>
+  </data>
+  <data name="FileTransfersTrayView_OutConnectionRate" xml:space="preserve">
+    <value>Out: {0}/s</value>
+    <comment>{0}: a value such as '1.2kb'</comment>
+  </data>
+  <data name="FileTransfersTrayView_Starting" xml:space="preserve">
+    <value>Starting...</value>
+    <comment>Displayed when an item is downloading, but we don't yet any any progress information for it</comment>
+  </data>
+  <data name="TimeAgo_Days_Plural" xml:space="preserve">
+    <value>{0} days ago</value>
+  </data>
+  <data name="TimeAgo_Days_Singular" xml:space="preserve">
+    <value>1 day ago</value>
+  </data>
+  <data name="TimeAgo_Hours_Plural" xml:space="preserve">
+    <value>{0} hours ago</value>
+  </data>
+  <data name="TimeAgo_Hours_Singular" xml:space="preserve">
+    <value>1 hour ago</value>
+  </data>
+  <data name="TimeAgo_JustNow" xml:space="preserve">
+    <value>Just now</value>
+  </data>
+  <data name="TimeAgo_Minutes_Plural" xml:space="preserve">
+    <value>{0} minutes ago</value>
+  </data>
+  <data name="TimeAgo_Minutes_Singular" xml:space="preserve">
+    <value>1 minute ago</value>
+  </data>
+  <data name="TimeAgo_Years_Plural" xml:space="preserve">
+    <value>{0} years ago</value>
+  </data>
+  <data name="TimeAgo_Years_Singular" xml:space="preserve">
+    <value>1 year ago</value>
+  </data>
+  <data name="FileTransfersTrayView_Downloading_RateKnown" xml:space="preserve">
+    <value>Downloading {0}/{1} ({2}/s)</value>
+    <comment>Displayed when a large file is downloaded
+{0}: amount downloaded so far (inserted as e.g. 4KB)
+{1}: The total amount to download
+{2}: The current download rate, e.g. 2.0MB</comment>
+  </data>
+  <data name="FileTransfersTrayView_Error" xml:space="preserve">
+    <value>Error: {0}</value>
+  </data>
 </root>

+ 3 - 15
src/SyncTrayzor/SyncThing/ApiClient/DownloadProgressEvent.cs

@@ -58,22 +58,10 @@ namespace SyncTrayzor.SyncThing.ApiClient
         public long BytesDone { get; set; }
     }
 
-    public class DownloadProgressEventFolderData
-    {
-        [JsonExtensionData]
-        public Dictionary<string, DownloadProgressEventFileData> Files { get; set; }
-    }
-
-    public class DownloadProgressEventData
-    {
-        [JsonExtensionData]
-        public Dictionary<string, DownloadProgressEventFolderData> Folders { get; set; }
-    }
-
     public class DownloadProgressEvent : Event
     {
         [JsonProperty("data")]
-        public DownloadProgressEventData Data { get; set; }
+        public Dictionary<string, Dictionary<string, DownloadProgressEventFileData>> Data { get; set; }
 
         public override void Visit(IEventVisitor visitor)
         {
@@ -83,9 +71,9 @@ namespace SyncTrayzor.SyncThing.ApiClient
         public override string ToString()
         {
             var sb = new StringBuilder();
-            foreach (var folder in this.Data.Folders)
+            foreach (var folder in this.Data)
             {
-                foreach (var file in folder.Value.Files)
+                foreach (var file in folder.Value)
                 {
                     sb.AppendFormat("{0}:{1}={2}/{3}", folder.Key, file.Key, file.Value.BytesDone, file.Value.BytesTotal);
                 }

+ 19 - 18
src/SyncTrayzor/SyncThing/ApiClient/ItemFinishedEvent.cs

@@ -1,4 +1,5 @@
 using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
 using System;
 using System.Collections.Generic;
 using System.Linq;
@@ -7,23 +8,6 @@ using System.Threading.Tasks;
 
 namespace SyncTrayzor.SyncThing.ApiClient
 {
-    public class ItemFinishedEventDataError
-    {
-        [JsonProperty("Op")]
-        public string Op { get; set; }
-
-        [JsonProperty("Path")]
-        public string Path { get; set; }
-
-        [JsonProperty("Err")]
-        public int ErrorCode { get; set; }
-
-        public override string ToString()
-        {
-            return String.Format("<Error Op={0} Path={1} Err={2}>", this.Op, this.Path, this.ErrorCode);
-        }
-    }
-
     public class ItemFinishedEventData
     {
         [JsonProperty("item")]
@@ -32,8 +16,25 @@ namespace SyncTrayzor.SyncThing.ApiClient
         [JsonProperty("folder")]
         public string Folder { get; set; }
 
+        // Irritatingly, 'error' is currently a structure containing an 'Err' property,
+        // but in the future may just become a string....
+
         [JsonProperty("error")]
-        public ItemFinishedEventDataError Error { get; set; }
+        public JToken ErrorRaw { get; set; }
+
+        public string Error
+        {
+            get
+            {
+                if (this.ErrorRaw == null)
+                    return null;
+                if (this.ErrorRaw.Type == JTokenType.String)
+                    return (string)this.ErrorRaw;
+                if (this.ErrorRaw.Type == JTokenType.Object)
+                    return (string)((JObject)this.ErrorRaw)["Err"];
+                return null;
+            }
+        }
 
         [JsonProperty("type")]
         public string Type { get; set; }

+ 3 - 2
src/SyncTrayzor/SyncThing/ApiClient/ItemStartedEvent.cs

@@ -2,6 +2,7 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using System.Runtime.Serialization;
 using System.Text;
 using System.Threading.Tasks;
 
@@ -34,8 +35,8 @@ namespace SyncTrayzor.SyncThing.ApiClient
 
         public override string ToString()
         {
-            return String.Format("<ItemStarted ID={0} Time={1} Item={2} Folder={3} Type={4}>",
-                this.Id, this.Time, this.Data.Item, this.Data.Folder, this.Data.Type);
+            return String.Format("<ItemStarted ID={0} Time={1} Item={2} Folder={3} Type={4} Action={5}>",
+                this.Id, this.Time, this.Data.Item, this.Data.Folder, this.Data.Type, this.Data.Action);
         }
     }
 }

+ 14 - 0
src/SyncTrayzor/SyncThing/EventWatcher/ItemChangedActionType.cs

@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SyncTrayzor.SyncThing.EventWatcher
+{
+    public enum ItemChangedActionType
+    {
+        Update,
+        Delete,
+    }
+}

+ 14 - 0
src/SyncTrayzor/SyncThing/EventWatcher/ItemChangedItemType.cs

@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SyncTrayzor.SyncThing.EventWatcher
+{
+    public enum ItemChangedItemType
+    {
+        File,
+        Folder,
+    }
+}

+ 23 - 0
src/SyncTrayzor/SyncThing/EventWatcher/ItemFinishedEventArgs.cs

@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SyncTrayzor.SyncThing.EventWatcher
+{
+    public class ItemFinishedEventArgs : ItemStateChangedEventArgs
+    {
+        public ItemChangedActionType Action { get; private set; }
+        public ItemChangedItemType ItemType { get; private set; }
+        public string Error { get; private set; }
+
+        public ItemFinishedEventArgs(string folder, string item, ItemChangedActionType action, ItemChangedItemType itemType, string error)
+            : base(folder, item)
+        {
+            this.Action = action;
+            this.ItemType = itemType;
+            this.Error = error;
+        }
+    }
+}

+ 21 - 0
src/SyncTrayzor/SyncThing/EventWatcher/ItemStartedEventArgs.cs

@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SyncTrayzor.SyncThing.EventWatcher
+{
+    public class ItemStartedEventArgs : ItemStateChangedEventArgs
+    {
+        public ItemChangedActionType Action { get; private set; }
+        public ItemChangedItemType ItemType { get; private set; }
+
+        public ItemStartedEventArgs(string folder, string item, ItemChangedActionType action, ItemChangedItemType itemType)
+            : base(folder, item)
+        {
+            this.Action = action;
+            this.ItemType = itemType;
+        }
+    }
+}

+ 28 - 12
src/SyncTrayzor/SyncThing/EventWatcher/SyncThingEventWatcher.cs

@@ -15,8 +15,8 @@ namespace SyncTrayzor.SyncThing.EventWatcher
     {
         event EventHandler<SyncStateChangedEventArgs> SyncStateChanged;
         event EventHandler StartupComplete;
-        event EventHandler<ItemStateChangedEventArgs> ItemStarted;
-        event EventHandler<ItemStateChangedEventArgs> ItemFinished;
+        event EventHandler<ItemStartedEventArgs> ItemStarted;
+        event EventHandler<ItemFinishedEventArgs> ItemFinished;
         event EventHandler<ItemDownloadProgressChangedEventArgs> ItemDownloadProgressChanged;
         event EventHandler<DeviceConnectedEventArgs> DeviceConnected;
         event EventHandler<DeviceDisconnectedEventArgs> DeviceDisconnected;
@@ -27,13 +27,23 @@ namespace SyncTrayzor.SyncThing.EventWatcher
         private static readonly Logger logger = LogManager.GetCurrentClassLogger();
         private readonly SynchronizedTransientWrapper<ISyncThingApiClient> apiClientWrapper;
         private ISyncThingApiClient apiClient;
+        private static readonly Dictionary<string, ItemChangedActionType> actionTypeMapping = new Dictionary<string, ItemChangedActionType>()
+        {
+            { "update", ItemChangedActionType.Update },
+            { "delete", ItemChangedActionType.Delete },
+        };
+        private static readonly Dictionary<string, ItemChangedItemType> itemTypeMapping = new Dictionary<string, ItemChangedItemType>()
+        {
+            { "file", ItemChangedItemType.File },
+            { "dir", ItemChangedItemType.Folder },
+        };
 
         private int lastEventId;
 
         public event EventHandler<SyncStateChangedEventArgs> SyncStateChanged;
         public event EventHandler StartupComplete;
-        public event EventHandler<ItemStateChangedEventArgs> ItemStarted;
-        public event EventHandler<ItemStateChangedEventArgs> ItemFinished;
+        public event EventHandler<ItemStartedEventArgs> ItemStarted;
+        public event EventHandler<ItemFinishedEventArgs> ItemFinished;
         public event EventHandler<ItemDownloadProgressChangedEventArgs> ItemDownloadProgressChanged;
         public event EventHandler<DeviceConnectedEventArgs> DeviceConnected;
         public event EventHandler<DeviceDisconnectedEventArgs> DeviceDisconnected;
@@ -91,18 +101,20 @@ namespace SyncTrayzor.SyncThing.EventWatcher
                 handler(this, EventArgs.Empty);
         }
 
-        private void OnItemStarted(string folder, string item)
+        private void OnItemStarted(string folder, string item, ItemChangedActionType action, ItemChangedItemType itemType)
         {
             var handler = this.ItemStarted;
             if (handler != null)
-                handler(this, new ItemStateChangedEventArgs(folder, item));
+            {
+                handler(this, new ItemStartedEventArgs(folder, item, action, itemType));
+            }
         }
 
-        private void OnItemFinished(string folder, string item)
+        private void OnItemFinished(string folder, string item, ItemChangedActionType action, ItemChangedItemType itemType, string error)
         {
             var handler = this.ItemFinished;
             if (handler != null)
-                handler(this, new ItemStateChangedEventArgs(folder, item));
+                handler(this, new ItemFinishedEventArgs(folder, item, action, itemType, error));
         }
 
         private void OnItemDownloadProgressChanged(string folder, string item, long bytesDone, long bytesTotal)
@@ -149,12 +161,16 @@ namespace SyncTrayzor.SyncThing.EventWatcher
 
         public void Accept(ItemStartedEvent evt)
         {
-            this.OnItemStarted(evt.Data.Folder, evt.Data.Item);
+            var actionType = actionTypeMapping[evt.Data.Action];
+            var itemType = itemTypeMapping[evt.Data.Type];
+            this.OnItemStarted(evt.Data.Folder, evt.Data.Item, actionType, itemType);
         }
 
         public void Accept(ItemFinishedEvent evt)
         {
-            this.OnItemFinished(evt.Data.Folder, evt.Data.Item);
+            var actionType = actionTypeMapping[evt.Data.Action];
+            var itemType = itemTypeMapping[evt.Data.Type];
+            this.OnItemFinished(evt.Data.Folder, evt.Data.Item, actionType, itemType, evt.Data.Error);
         }
 
         public void Accept(StartupCompleteEvent evt)
@@ -174,9 +190,9 @@ namespace SyncTrayzor.SyncThing.EventWatcher
 
         public void Accept(DownloadProgressEvent evt)
         {
-            foreach (var folder in evt.Data.Folders)
+            foreach (var folder in evt.Data)
             {
-                foreach (var file in folder.Value.Files)
+                foreach (var file in folder.Value)
                 {
                     this.OnItemDownloadProgressChanged(folder.Key, file.Key, file.Value.BytesDone, file.Value.BytesTotal);
                 }

+ 1 - 2
src/SyncTrayzor/SyncThing/SyncThingManager.cs

@@ -319,8 +319,7 @@ namespace SyncTrayzor.SyncThing
                 if (apiClient == null)
                     throw new InvalidOperationException("ApiClient must not be null");
 
-                this.connectionsWatcher.Stop();
-
+                this.connectionsWatcher.Start();
                 this.eventWatcher.Start();
             }
         }

+ 30 - 5
src/SyncTrayzor/SyncThing/TransferHistory/FileTransfer.cs

@@ -1,4 +1,5 @@
-using System;
+using SyncTrayzor.SyncThing.EventWatcher;
+using System;
 using System.Collections.Generic;
 using System.Linq;
 using System.Text;
@@ -9,31 +10,55 @@ namespace SyncTrayzor.SyncThing.TransferHistory
     public class FileTransfer
     {
         public FileTransferStatus Status { get; set; }
-        public long BytesTransferred;
-        public long TotalBytes { get; set; }
+
+        public long BytesTransferred { get; private set; }
+        public long TotalBytes { get; private set; }
+        public double? DownloadBytesPerSecond { get; private set; }
 
         public string FolderId { get; private set; }
         public string Path { get; private set; }
+        public ItemChangedItemType ItemType { get; private set; }
+        public ItemChangedActionType ActionType { get; private set; }
+
+        public DateTime StartedUtc { get; private set; }
+        public DateTime? FinishedUtc { get; private set; }
+
+        public string Error { get; private set; }
 
-        public FileTransfer(string folderId, string path)
+        private DateTime? lastProgressUpdateUtc;
+
+        public FileTransfer(string folderId, string path, ItemChangedItemType itemType, ItemChangedActionType actionType)
         {
             this.FolderId = folderId;
             this.Path = path;
 
             this.Status = FileTransferStatus.Started;
+            this.StartedUtc = DateTime.UtcNow;
+            this.ItemType = itemType;
+            this.ActionType = actionType;
         }
 
         public void SetDownloadProgress(long bytesTransferred, long totalBytes)
         {
+            var now = DateTime.UtcNow;
+            if (this.lastProgressUpdateUtc.HasValue)
+            {
+                var deltaBytesTransferred = bytesTransferred - this.BytesTransferred;
+                this.DownloadBytesPerSecond = deltaBytesTransferred / (now - this.lastProgressUpdateUtc.Value).TotalSeconds;
+            }
+
             this.BytesTransferred = bytesTransferred;
             this.TotalBytes = totalBytes;
             this.Status = FileTransferStatus.InProgress;
+            this.lastProgressUpdateUtc = now;
         }
 
-        public void SetComplete()
+        public void SetComplete(string error)
         {
             this.Status = FileTransferStatus.Completed;
             this.BytesTransferred = this.TotalBytes;
+            this.FinishedUtc = DateTime.UtcNow;
+            this.Error = error;
         }
     }
 }

+ 26 - 18
src/SyncTrayzor/SyncThing/TransferHistory/SyncThingTransferHistory.cs

@@ -71,44 +71,45 @@ namespace SyncTrayzor.SyncThing.TransferHistory
             this.eventWatcher.ItemDownloadProgressChanged += this.ItemDownloadProgressChanged;
         }
 
-        private FileTransfer FetchOrInsertInProgressFileTransfer(string folder, string path)
+        private FileTransfer FetchOrInsertInProgressFileTransfer(string folder, string path, ItemChangedItemType itemType, ItemChangedActionType actionType)
         {
             var key = this.KeyForFileTransfer(folder, path);
+            bool created = false;
             FileTransfer fileTransfer;
             lock (this.transfersLock)
             {
                 if (!this.inProgressTransfers.TryGetValue(key, out fileTransfer))
                 {
-                    fileTransfer = new FileTransfer(folder, path);
+                    created = true;
+                    fileTransfer = new FileTransfer(folder, path, itemType, actionType);
                     this.inProgressTransfers.Add(key, fileTransfer);
                 }
-
-                return fileTransfer;
             }
+
+            if (created)
+                this.OnTransferStarted(fileTransfer);
+            return fileTransfer;
         }
 
-        private void ItemStarted(object sender, ItemStateChangedEventArgs e)
+        private void ItemStarted(object sender, ItemStartedEventArgs e)
         {
-            var fileTransfer = this.FetchOrInsertInProgressFileTransfer(e.Folder, e.Item);
-            this.OnTransferStarted(fileTransfer);
+            this.FetchOrInsertInProgressFileTransfer(e.Folder, e.Item, e.ItemType, e.Action);
         }
 
-        private void ItemFinished(object sender, ItemStateChangedEventArgs e)
+        private void ItemFinished(object sender, ItemFinishedEventArgs e)
         {
             // It *should* be in the 'in progress transfers'...
-            FileTransfer fileTransfer = null;
+            FileTransfer fileTransfer;
             lock (this.transfersLock)
             {
                 var key = this.KeyForFileTransfer(e.Folder, e.Item);
-                if (this.inProgressTransfers.TryGetValue(key, out fileTransfer))
-                {
-                    fileTransfer.SetComplete();
-                    this.inProgressTransfers.Remove(key);
+                fileTransfer = this.FetchOrInsertInProgressFileTransfer(e.Folder, e.Item, e.ItemType, e.Action);
+                fileTransfer.SetComplete(e.Error);
+                this.inProgressTransfers.Remove(key);
 
-                    this.completedTransfers.Enqueue(fileTransfer);
-                    if (this.completedTransfers.Count > maxCompletedTransfers)
-                        this.completedTransfers.Dequeue();
-                }
+                this.completedTransfers.Enqueue(fileTransfer);
+                if (this.completedTransfers.Count > maxCompletedTransfers)
+                    this.completedTransfers.Dequeue();
             }
 
             if (fileTransfer != null)
@@ -120,7 +121,14 @@ namespace SyncTrayzor.SyncThing.TransferHistory
 
         private void ItemDownloadProgressChanged(object sender, ItemDownloadProgressChangedEventArgs e)
         {
-            var fileTransfer = this.FetchOrInsertInProgressFileTransfer(e.Folder, e.Item);
+            // If we didn't see the started event, tough. We don't have enough information to re-create it...
+            var key = this.KeyForFileTransfer(e.Folder, e.Item);
+            FileTransfer fileTransfer;
+            lock (this.transfersLock)
+            {
+                if (!this.inProgressTransfers.TryGetValue(key, out fileTransfer))
+                    return; // Nothing we can do...
+            }
             fileTransfer.SetDownloadProgress(e.BytesDone, e.BytesTotal);
 
             this.OnTransferStateChanged(fileTransfer);

+ 14 - 0
src/SyncTrayzor/SyncTrayzor.csproj

@@ -108,6 +108,7 @@
     <Reference Include="System.Drawing" />
     <Reference Include="System.Net.Http" />
     <Reference Include="System.Net.Http.WebRequest" />
+    <Reference Include="System.Runtime.Serialization" />
     <Reference Include="System.Windows.Interactivity, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL" />
     <Reference Include="System.Xaml" />
     <Reference Include="System.Xml" />
@@ -125,8 +126,10 @@
       <DependentUpon>App.xaml</DependentUpon>
       <SubType>Code</SubType>
     </Compile>
+    <Compile Include="Design\DummyFileTransfersTrayViewModel.cs" />
     <Compile Include="NotifyIcon\BalloonConductor.cs" />
     <Compile Include="NotifyIcon\NotifyIconResolutionUtilities.cs" />
+    <Compile Include="Pages\FileTransfersTrayViewModel.cs" />
     <Compile Include="Pages\NewVersionInstalledToastViewModel.cs" />
     <Compile Include="Properties\Settings.Designer.cs">
       <AutoGen>True</AutoGen>
@@ -187,6 +190,10 @@
     <Compile Include="SyncThing\ApiClient\ISyncThingApiV0p11.cs" />
     <Compile Include="SyncThing\ApiClient\SyncThingApiClientFactory.cs" />
     <Compile Include="SyncThing\ApiClient\SyncThingApiClientV0p11.cs" />
+    <Compile Include="SyncThing\EventWatcher\ItemChangedActionType.cs" />
+    <Compile Include="SyncThing\EventWatcher\ItemChangedItemType.cs" />
+    <Compile Include="SyncThing\EventWatcher\ItemFinishedEventArgs.cs" />
+    <Compile Include="SyncThing\EventWatcher\ItemStartedEventArgs.cs" />
     <Compile Include="SyncThing\SyncThingHttpClientHandler.cs" />
     <Compile Include="SyncThing\EventWatcher\ItemDownloadProgressChangedEventArgs.cs" />
     <Compile Include="SyncThing\SyncThingDidNotStartCorrectlyException.cs" />
@@ -292,11 +299,14 @@
     <Compile Include="Utils\PathEx.cs" />
     <Compile Include="Utils\SafeSyncthingExtensions.cs" />
     <Compile Include="Utils\SemaphoreSlimExtensions.cs" />
+    <Compile Include="Utils\ShellTools.cs" />
     <Compile Include="Utils\StreamExtensions.cs" />
     <Compile Include="Utils\SynchronizedEventDispatcher.cs" />
+    <Compile Include="Design\ViewModelLocator.cs" />
     <Compile Include="Xaml\GridLengthToAbsoluteConverter.cs" />
     <Compile Include="Xaml\MouseWheelGesture.cs" />
     <Compile Include="Xaml\NoSizeBelowScreenBehaviour.cs" />
+    <Compile Include="Xaml\PopupConductorBehaviour.cs" />
     <Compile Include="Xaml\RemoveMnemonicsConverter.cs" />
     <Compile Include="Xaml\UacImageSource.cs" />
     <Compile Include="Utils\UriExtensions.cs" />
@@ -359,6 +369,10 @@
       <SubType>Designer</SubType>
       <Generator>MSBuild:Compile</Generator>
     </Page>
+    <Page Include="Pages\FileTransfersTrayView.xaml">
+      <SubType>Designer</SubType>
+      <Generator>MSBuild:Compile</Generator>
+    </Page>
     <Page Include="Pages\NewVersionAlertToastView.xaml">
       <SubType>Designer</SubType>
       <Generator>MSBuild:Compile</Generator>

+ 34 - 4
src/SyncTrayzor/Utils/FormatUtils.cs

@@ -1,4 +1,5 @@
-using System;
+using SyncTrayzor.Properties.Strings;
+using System;
 using System.Collections.Generic;
 using System.Linq;
 using System.Text;
@@ -10,7 +11,7 @@ namespace SyncTrayzor.Utils
     {
         private static readonly string[] sizes = { "B", "KB", "MB", "GB" };
 
-        public static string BytesToHuman(long bytes)
+        public static string BytesToHuman(double bytes, int decimalPlaces = 0)
         {
             // http://stackoverflow.com/a/281679/1086121
             int order = 0;
@@ -19,7 +20,36 @@ namespace SyncTrayzor.Utils
                 order++;
                 bytes = bytes / 1024;
             }
-            return String.Format("{0:0.#}{1}", bytes, sizes[order]);
+            var placesFmtString = new String('0', decimalPlaces);
+            return String.Format("{0:0." + placesFmtString + "}{1}", bytes, sizes[order]);
+        }
+
+        public static string TimeSpanToTimeAgo(TimeSpan timeSpan)
+        {
+            if (timeSpan.TotalDays > 365)
+            {
+                int years = (int)Math.Ceiling((float)timeSpan.Days / 365);
+                return years == 1 ?
+                    Resources.TimeAgo_Years_Singular :
+                    String.Format(Resources.TimeAgo_Years_Plural, years);
+            }
+
+            if (timeSpan.TotalDays > 1.0)
+                return (int)timeSpan.TotalDays == 1 ?
+                    Resources.TimeAgo_Days_Singular :
+                    String.Format(Resources.TimeAgo_Days_Plural, (int)timeSpan.TotalDays);
+
+            if (timeSpan.TotalHours > 1.0)
+                return (int)timeSpan.TotalHours == 1 ?
+                    Resources.TimeAgo_Hours_Singular :
+                    String.Format(Resources.TimeAgo_Hours_Plural, (int)timeSpan.TotalHours);
+
+            if (timeSpan.TotalMinutes > 1.0)
+                return (int)timeSpan.TotalMinutes == 1 ?
+                    Resources.TimeAgo_Minutes_Singular :
+                    String.Format(Resources.TimeAgo_Minutes_Plural, (int)timeSpan.TotalMinutes);
+
+            return Resources.TimeAgo_JustNow;
         }
     }
-}
+}

+ 64 - 0
src/SyncTrayzor/Utils/ShellTools.cs

@@ -0,0 +1,64 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SyncTrayzor.Utils
+{
+    // See http://codesdirectory.blogspot.co.uk/2013/01/displaying-system-icon-in-c-wpf.html
+    public static class ShellTools
+    {
+        public static Icon GetIcon(string path, bool isFile)
+        {
+            var flags = (uint)(SHGFI_ICON | SHGFI_USEFILEATTRIBUTES | SHGFI_LARGEICON);
+            var attribute = isFile ? (uint)FILE_ATTRIBUTE_FILE : (uint)FILE_ATTRIBUTE_DIRECTORY;
+            var shfi = new SHFileInfo();
+            var res = SHGetFileInfo(path, attribute, out shfi, (uint)Marshal.SizeOf(shfi), flags);
+
+            if (res == IntPtr.Zero)
+                throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error());
+
+            try
+            {
+                Icon.FromHandle(shfi.hIcon);
+                return (Icon)Icon.FromHandle(shfi.hIcon).Clone();
+            }
+            finally
+            {
+                DestroyIcon(shfi.hIcon);
+            }
+        }
+
+        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
+        private struct SHFileInfo
+        {
+            public IntPtr hIcon;
+            public int iIcon;
+            public uint dwAttributes;
+
+            [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
+            public string szDisplayName;
+
+            [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
+            public string szTypeName;
+        }
+
+        private const uint SHGFI_ICON = 0x000000100;
+        private const uint SHGFI_USEFILEATTRIBUTES = 0x000000010;
+        private const uint SHGFI_OPENICON = 0x000000002;
+        private const uint SHGFI_SMALLICON = 0x000000001;
+        private const uint SHGFI_LARGEICON = 0x000000000;
+        private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
+        private const uint FILE_ATTRIBUTE_FILE = 0x00000100;
+
+        [DllImport("shell32.dll", CharSet = CharSet.Auto)]
+        private static extern IntPtr SHGetFileInfo(string pszPath, uint dwFileAttributes, out SHFileInfo psfi, uint cbFileInfo, uint uFlags);
+
+        [DllImport("user32.dll", SetLastError = true)]
+        [return: MarshalAs(UnmanagedType.Bool)]
+        private static extern bool DestroyIcon(IntPtr hIcon);
+    }
+}

+ 49 - 0
src/SyncTrayzor/Xaml/PopupConductorBehaviour.cs

@@ -0,0 +1,49 @@
+using Stylet;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls.Primitives;
+
+namespace SyncTrayzor.Xaml
+{
+    public class PopupConductorBehaviour : DetachingBehaviour<Popup>
+    {
+        public object DataContext
+        {
+            get { return (object)GetValue(DataContextProperty); }
+            set { SetValue(DataContextProperty, value); }
+        }
+
+        public static readonly DependencyProperty DataContextProperty =
+            DependencyProperty.Register("DataContext", typeof(object), typeof(PopupConductorBehaviour), new PropertyMetadata(null));
+
+        protected override void AttachHandlers()
+        {
+            this.AssociatedObject.Opened += this.Opened;
+            this.AssociatedObject.Closed += this.Closed;
+        }
+
+        protected override void DetachHandlers()
+        {
+            this.AssociatedObject.Opened -= this.Opened;
+            this.AssociatedObject.Closed -= this.Closed;
+        }
+
+        private void Opened(object sender, EventArgs e)
+        {
+            var screenState = this.DataContext as IScreenState;
+            if (screenState != null)
+                screenState.Activate();
+        }
+
+        private void Closed(object sender, EventArgs e)
+        {
+            var screenState = this.DataContext as IScreenState;
+            if (screenState != null)
+                screenState.Close();
+        }
+    }
+}