1
0
Эх сурвалжийг харах

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 жил өмнө
parent
commit
5c8370c54b
26 өөрчлөгдсөн 1018 нэмэгдсэн , 85 устгасан
  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();
+        }
+    }
+}