From d1cdff9c05a58de0b836777bf7e0b34f2092d012 Mon Sep 17 00:00:00 2001 From: Roland Pheasant Date: Thu, 17 Dec 2015 20:47:29 +0000 Subject: [PATCH] Recent Files --- ...ecentFiles.cs => IRecentFileCollection.cs} | 6 +- .../FileHandling/RecentFile.cs | 65 ++++++++++++++++ ...RecentFiles.cs => RecentFileCollection.cs} | 39 +++------- .../RecentFilesToStateConverter.cs | 42 +++++++--- .../Settings/SettingsStore.cs | 36 ++++----- Source/TailBlazer.Domain/Settings/XmlEx.cs | 17 +++- .../TailBlazer.Domain.csproj | 5 +- .../RecentFilesToStateConverterFixture.cs | 9 ++- .../SettingsStoreFixture.cs | 2 +- Source/TailBlazer/App.xaml.cs | 1 - .../Infrastucture/AppConventions.cs | 2 +- Source/TailBlazer/MainWindow.xaml | 34 ++------ Source/TailBlazer/TailBlazer.csproj | 11 +++ Source/TailBlazer/Themes/Generic.xaml | 43 +++++++++- Source/TailBlazer/Views/OpenFileIcon.cs | 54 +++++++++++++ Source/TailBlazer/Views/RecentFileProxy.cs | 78 +++++++++++++++++++ Source/TailBlazer/Views/RecentFilesView.xaml | 77 ++++++++++++++++++ .../TailBlazer/Views/RecentFilesView.xaml.cs | 28 +++++++ .../TailBlazer/Views/RecentFilesViewModel.cs | 63 +++++++++++++++ Source/TailBlazer/Views/RemoveIcon.cs | 26 +++++++ Source/TailBlazer/Views/WindowViewModel.cs | 43 +++++----- 21 files changed, 561 insertions(+), 120 deletions(-) rename Source/TailBlazer.Domain/FileHandling/{IRecentFiles.cs => IRecentFileCollection.cs} (60%) create mode 100644 Source/TailBlazer.Domain/FileHandling/RecentFile.cs rename Source/TailBlazer.Domain/FileHandling/{RecentFiles.cs => RecentFileCollection.cs} (66%) create mode 100644 Source/TailBlazer/Views/OpenFileIcon.cs create mode 100644 Source/TailBlazer/Views/RecentFileProxy.cs create mode 100644 Source/TailBlazer/Views/RecentFilesView.xaml create mode 100644 Source/TailBlazer/Views/RecentFilesView.xaml.cs create mode 100644 Source/TailBlazer/Views/RecentFilesViewModel.cs create mode 100644 Source/TailBlazer/Views/RemoveIcon.cs diff --git a/Source/TailBlazer.Domain/FileHandling/IRecentFiles.cs b/Source/TailBlazer.Domain/FileHandling/IRecentFileCollection.cs similarity index 60% rename from Source/TailBlazer.Domain/FileHandling/IRecentFiles.cs rename to Source/TailBlazer.Domain/FileHandling/IRecentFileCollection.cs index cfb11707..08b4040f 100644 --- a/Source/TailBlazer.Domain/FileHandling/IRecentFiles.cs +++ b/Source/TailBlazer.Domain/FileHandling/IRecentFileCollection.cs @@ -4,10 +4,12 @@ namespace TailBlazer.Domain.FileHandling { - public interface IRecentFiles + public interface IRecentFileCollection { IObservableList Items { get; } - void Register(FileInfo file); + void Add(RecentFile file); + + void Remove(RecentFile file); } } diff --git a/Source/TailBlazer.Domain/FileHandling/RecentFile.cs b/Source/TailBlazer.Domain/FileHandling/RecentFile.cs new file mode 100644 index 00000000..6e43d43b --- /dev/null +++ b/Source/TailBlazer.Domain/FileHandling/RecentFile.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; + +namespace TailBlazer.Domain.FileHandling +{ + public class RecentFile : IEquatable + { + public DateTime Timestamp { get; } + public string Name { get; } + + public RecentFile(FileInfo fileInfo) + { + Name = fileInfo.FullName; + Timestamp = DateTime.Now; + } + + public RecentFile(DateTime timestamp, string name) + { + Timestamp = timestamp; + Name = name; + } + + #region Equality + + public bool Equals(RecentFile other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Timestamp.Equals(other.Timestamp) && string.Equals(Name, other.Name); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((RecentFile) obj); + } + + public override int GetHashCode() + { + unchecked + { + return (Timestamp.GetHashCode()*397) ^ (Name != null ? Name.GetHashCode() : 0); + } + } + + public static bool operator ==(RecentFile left, RecentFile right) + { + return Equals(left, right); + } + + public static bool operator !=(RecentFile left, RecentFile right) + { + return !Equals(left, right); + } + + #endregion + + public override string ToString() + { + return $"{Name} ({Timestamp})"; + } + } +} \ No newline at end of file diff --git a/Source/TailBlazer.Domain/FileHandling/RecentFiles.cs b/Source/TailBlazer.Domain/FileHandling/RecentFileCollection.cs similarity index 66% rename from Source/TailBlazer.Domain/FileHandling/RecentFiles.cs rename to Source/TailBlazer.Domain/FileHandling/RecentFileCollection.cs index 8c0168b7..bd0a759e 100644 --- a/Source/TailBlazer.Domain/FileHandling/RecentFiles.cs +++ b/Source/TailBlazer.Domain/FileHandling/RecentFileCollection.cs @@ -8,26 +8,7 @@ namespace TailBlazer.Domain.FileHandling { - - public class RecentFile - { - public DateTime Timestamp { get; } - public string Name { get; } - - public RecentFile(FileInfo fileInfo) - { - Name = fileInfo.FullName; - Timestamp = DateTime.Now; - } - - public RecentFile(DateTime timestamp, string name) - { - Timestamp = timestamp; - Name = name; - } - } - - public class RecentFiles : IRecentFiles, IDisposable + public class RecentFileCollection : IRecentFileCollection, IDisposable { private const string SettingsKey = "RecentFiles"; @@ -37,7 +18,7 @@ public class RecentFiles : IRecentFiles, IDisposable public IObservableList Items { get; } - public RecentFiles(ILogger logger, ISettingFactory settingFactory, ISettingsStore store) + public RecentFileCollection(ILogger logger, ISettingFactory settingFactory, ISettingsStore store) { if (logger == null) throw new ArgumentNullException(nameof(logger)); if (store == null) throw new ArgumentNullException(nameof(store)); @@ -55,11 +36,11 @@ public RecentFiles(ILogger logger, ISettingFactory settingFactory, ISettingsStor _files.Edit(innerCache => { //all files are loaded when state changes, so only add new ones - //var newItems = files - // .Where(f => !innerCache.Lookup(f.FullName).HasValue) - // .ToArray(); + var newItems = files + .Where(f => !innerCache.Lookup(f.Name).HasValue) + .ToArray(); - //innerCache.AddOrUpdate(newItems); + innerCache.AddOrUpdate(newItems); }); }); @@ -73,16 +54,16 @@ public RecentFiles(ILogger logger, ISettingFactory settingFactory, ISettingsStor _cleanUp = new CompositeDisposable(settingsWriter, loader, _files,Items); } - public void Register(FileInfo file) + public void Add(RecentFile file) { if (file == null) throw new ArgumentNullException(nameof(file)); - _files.AddOrUpdate(new RecentFile(file)); + _files.AddOrUpdate(file); } - public void Remove(FileInfo file) + public void Remove(RecentFile file) { - _files.Remove(file.Name); + _files.Remove(file); } diff --git a/Source/TailBlazer.Domain/FileHandling/RecentFilesToStateConverter.cs b/Source/TailBlazer.Domain/FileHandling/RecentFilesToStateConverter.cs index 8e6cac6f..9d65342d 100644 --- a/Source/TailBlazer.Domain/FileHandling/RecentFilesToStateConverter.cs +++ b/Source/TailBlazer.Domain/FileHandling/RecentFilesToStateConverter.cs @@ -2,6 +2,7 @@ using System.Collections; using System.IO; using System.Linq; +using System.Xml; using System.Xml.Linq; using TailBlazer.Domain.Settings; @@ -9,15 +10,36 @@ namespace TailBlazer.Domain.FileHandling { public class RecentFilesToStateConverter: IConverter { - public RecentFile[] Convert(State state) + private static class Structure { + public const string Root = "Files"; + public const string File = "File"; + public const string Name = "Name"; + public const string Date = "Date"; + } - + public RecentFile[] Convert(State state) + { if (state == null || state == State.Empty) return new RecentFile[0]; - - return null; - // return state.Value.FromDelimited(s => new RecentFile(s),Environment.NewLine).ToArray(); + + //previous format + if (state.Version== 1) + return state.Value.FromDelimited(s => new RecentFile(new FileInfo(s)), Environment.NewLine).ToArray(); + + //v2 format is xml format + XDocument doc = XDocument.Parse(state.Value); + + var root = doc.ElementOrThrow(Structure.Root); + + var files = root.Elements(Structure.File) + .Select(element => + { + var name = element.Attribute(Structure.Name).Value; + var dateTime = element.Attribute(Structure.Date).Value; + return new RecentFile(DateTime.Parse(dateTime),name); + }).ToArray(); + return files; } public State Convert(RecentFile[] files) @@ -25,16 +47,16 @@ public State Convert(RecentFile[] files) if (files == null || !files.Any()) return State.Empty; - var root = new XElement(new XElement("Files", new XAttribute("Version",1))); + var root = new XElement(new XElement(Structure.Root)); - var fileNodeArray = files.Select(f => new XElement("File", - new XAttribute("Name", f.Name), - new XAttribute("Date", f.Timestamp))); + var fileNodeArray = files.Select(f => new XElement(Structure.File, + new XAttribute(Structure.Name, f.Name), + new XAttribute(Structure.Date, f.Timestamp))); fileNodeArray.ForEach(root.Add); XDocument doc = new XDocument(root); - return new State(1, doc.ToString()); + return new State(2, doc.ToString()); } public RecentFile[] GetDefaultValue() diff --git a/Source/TailBlazer.Domain/Settings/SettingsStore.cs b/Source/TailBlazer.Domain/Settings/SettingsStore.cs index 96bc7b76..ab7e45bb 100644 --- a/Source/TailBlazer.Domain/Settings/SettingsStore.cs +++ b/Source/TailBlazer.Domain/Settings/SettingsStore.cs @@ -11,6 +11,14 @@ public class FileSettingsStore : ISettingsStore private readonly ILogger _logger; private string Location { get; } + + private static class Structure + { + public const string Root = "Setting"; + public const string Version = "Version"; + public const string State = "State"; + } + public FileSettingsStore(ILogger logger) { _logger = logger; @@ -28,23 +36,14 @@ public void Save(string key, State state) _logger.Info($"Creating setting for {key}"); - var writer = new StringWriter(); - using (var xmlWriter = new XmlTextWriter(writer)) - { - using (xmlWriter.WriteElement("Setting")) - { - xmlWriter.WriteAttributeString("Version",state.Version.ToString()); - using (xmlWriter.WriteElement("State")) - { - xmlWriter.WriteString(state.Value); - } - } - xmlWriter.Close(); - } - - var formatted = XDocument.Parse(writer.ToString()); - _logger.Info($"Writing value {formatted.ToString()}"); - File.WriteAllText(file, formatted.ToString()); + var root = new XElement(new XElement(Structure.Root, new XAttribute(Structure.Version,state.Version))); + var stateElement = new XElement(Structure.State, state.Value); + root.Add(stateElement); + var doc = new XDocument(root); + var fileText = doc.ToString(); + + _logger.Info($"Writing value {fileText}"); + File.WriteAllText(file, fileText); } @@ -57,10 +56,9 @@ public State Load(string key) if (!info.Exists || info.Length == 0) return State.Empty; - var doc = XDocument.Load(file); - var root = doc.Element("Setting"); + var root = doc.ElementOrThrow("Setting"); var versionString = root.AttributeOrThrow("Version"); var version = int.Parse(versionString); var state = root.ElementOrThrow("State"); diff --git a/Source/TailBlazer.Domain/Settings/XmlEx.cs b/Source/TailBlazer.Domain/Settings/XmlEx.cs index c83f13e3..34151ba0 100644 --- a/Source/TailBlazer.Domain/Settings/XmlEx.cs +++ b/Source/TailBlazer.Domain/Settings/XmlEx.cs @@ -16,6 +16,20 @@ public static IDisposable WriteElement(this XmlTextWriter source, string element return Disposable.Create(source.WriteEndElement); } + public static XElement ElementOrThrow(this XDocument source, string elementName) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (elementName == null) throw new ArgumentNullException(nameof(elementName)); + + var element = source.Element(elementName); + + if (element == null) + throw new ArgumentNullException($"{elementName} does not exist"); + + + return element; + } + public static string ElementOrThrow(this XElement source, string elementName) { if (source == null) throw new ArgumentNullException(nameof(source)); @@ -40,8 +54,9 @@ public static string AttributeOrThrow(this XElement source, string elementName) if (element == null) throw new ArgumentNullException($"{elementName} does not exist"); - return element.Value; } + + } } \ No newline at end of file diff --git a/Source/TailBlazer.Domain/TailBlazer.Domain.csproj b/Source/TailBlazer.Domain/TailBlazer.Domain.csproj index ad46041c..bdad5cb4 100644 --- a/Source/TailBlazer.Domain/TailBlazer.Domain.csproj +++ b/Source/TailBlazer.Domain/TailBlazer.Domain.csproj @@ -93,8 +93,9 @@ - - + + + diff --git a/Source/TailBlazer.Fixtures/RecentFilesToStateConverterFixture.cs b/Source/TailBlazer.Fixtures/RecentFilesToStateConverterFixture.cs index c2f19fa1..19df41f8 100644 --- a/Source/TailBlazer.Fixtures/RecentFilesToStateConverterFixture.cs +++ b/Source/TailBlazer.Fixtures/RecentFilesToStateConverterFixture.cs @@ -1,4 +1,5 @@ using System.IO; +using FluentAssertions; using TailBlazer.Domain.FileHandling; using Xunit; @@ -7,7 +8,7 @@ namespace TailBlazer.Fixtures public class RecentFilesToStateConverterFixture { [Fact] - public void ConvertFiles() + public void TwoWayConversion() { var files = new[] @@ -17,9 +18,9 @@ public void ConvertFiles() }; var converter = new RecentFilesToStateConverter(); - - var converted = converter.Convert(files); - + var state = converter.Convert(files); + var restored = converter.Convert(state); + restored.ShouldAllBeEquivalentTo(files); } } } \ No newline at end of file diff --git a/Source/TailBlazer.Fixtures/SettingsStoreFixture.cs b/Source/TailBlazer.Fixtures/SettingsStoreFixture.cs index ebbe59a3..9771968e 100644 --- a/Source/TailBlazer.Fixtures/SettingsStoreFixture.cs +++ b/Source/TailBlazer.Fixtures/SettingsStoreFixture.cs @@ -27,7 +27,7 @@ public void WriteState() [Fact] public void WriteComplexState() { - var state = new State(1, "<"); + var state = new State(1, "< which breaks xml {}"); var store = new FileSettingsStore(new NullLogger()); store.Save("wierdfile", state); diff --git a/Source/TailBlazer/App.xaml.cs b/Source/TailBlazer/App.xaml.cs index b0ebfb1c..8e3facde 100644 --- a/Source/TailBlazer/App.xaml.cs +++ b/Source/TailBlazer/App.xaml.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Configuration; -using System.Data; using System.Linq; using System.Threading.Tasks; using System.Windows; diff --git a/Source/TailBlazer/Infrastucture/AppConventions.cs b/Source/TailBlazer/Infrastucture/AppConventions.cs index e8c21cc9..59f573bb 100644 --- a/Source/TailBlazer/Infrastucture/AppConventions.cs +++ b/Source/TailBlazer/Infrastucture/AppConventions.cs @@ -15,7 +15,7 @@ public void Process(Type type, Registry registry) // Only work on concrete types if (!type.IsConcrete() || type.IsGenericType) return; - // Register against all the interfaces implemented + // Add against all the interfaces implemented // by this concrete class type.GetInterfaces() .Where(@interface => @interface.Name == $"I{type.Name}" ) diff --git a/Source/TailBlazer/MainWindow.xaml b/Source/TailBlazer/MainWindow.xaml index f50ec0eb..20762de8 100644 --- a/Source/TailBlazer/MainWindow.xaml +++ b/Source/TailBlazer/MainWindow.xaml @@ -68,6 +68,7 @@ + + + + + + + + + + + + + + diff --git a/Source/TailBlazer/Views/RecentFilesView.xaml.cs b/Source/TailBlazer/Views/RecentFilesView.xaml.cs new file mode 100644 index 00000000..d25823fd --- /dev/null +++ b/Source/TailBlazer/Views/RecentFilesView.xaml.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace TailBlazer.Views +{ + /// + /// Interaction logic for RecentFilesView.xaml + /// + public partial class RecentFilesView : UserControl + { + public RecentFilesView() + { + InitializeComponent(); + } + } +} diff --git a/Source/TailBlazer/Views/RecentFilesViewModel.cs b/Source/TailBlazer/Views/RecentFilesViewModel.cs new file mode 100644 index 00000000..a521b0ff --- /dev/null +++ b/Source/TailBlazer/Views/RecentFilesViewModel.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using DynamicData; +using DynamicData.Binding; +using TailBlazer.Domain.FileHandling; +using TailBlazer.Domain.Infrastructure; + +namespace TailBlazer.Views +{ + public class RecentFilesViewModel:AbstractNotifyPropertyChanged, IDisposable + { + private readonly IRecentFileCollection _recentFileCollection; + private readonly IDisposable _cleanUp; + private readonly ISubject _fileOpenRequest = new Subject(); + + public ReadOnlyObservableCollection Files {get;} + + public RecentFilesViewModel(IRecentFileCollection recentFileCollection, ISchedulerProvider schedulerProvider) + { + _recentFileCollection = recentFileCollection; + if (recentFileCollection == null) throw new ArgumentNullException(nameof(recentFileCollection)); + if (schedulerProvider == null) throw new ArgumentNullException(nameof(schedulerProvider)); + + + ReadOnlyObservableCollection data; + var recentLoader = recentFileCollection.Items + .Connect() + .Transform(rf => new RecentFileProxy(rf, toOpen => + { + _fileOpenRequest.OnNext(new FileInfo(toOpen.Name)); + }, + recentFileCollection.Remove)) + .Sort(SortExpressionComparer.Descending(proxy => proxy.Timestamp)) + .ObserveOn(schedulerProvider.MainThread) + .Bind(out data) + .Subscribe(); + + Files = data; + + _cleanUp = Disposable.Create(() => + { + recentLoader.Dispose(); + _fileOpenRequest.OnCompleted(); + }) ; + } + + public IObservable OpenFileRequest => _fileOpenRequest.AsObservable(); + + public void Add(FileInfo fileInfo) + { + _recentFileCollection.Add(new RecentFile(fileInfo)); + } + + public void Dispose() + { + _cleanUp.Dispose(); + } + } +} diff --git a/Source/TailBlazer/Views/RemoveIcon.cs b/Source/TailBlazer/Views/RemoveIcon.cs new file mode 100644 index 00000000..67e480e2 --- /dev/null +++ b/Source/TailBlazer/Views/RemoveIcon.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace TailBlazer.Views +{ + + public class RemoveIcon : Control + { + static RemoveIcon() + { + DefaultStyleKeyProperty.OverrideMetadata(typeof(RemoveIcon), new FrameworkPropertyMetadata(typeof(RemoveIcon))); + } + } +} diff --git a/Source/TailBlazer/Views/WindowViewModel.cs b/Source/TailBlazer/Views/WindowViewModel.cs index 3692ed7a..2a19546b 100644 --- a/Source/TailBlazer/Views/WindowViewModel.cs +++ b/Source/TailBlazer/Views/WindowViewModel.cs @@ -9,32 +9,31 @@ using System.Reflection; using System.Windows.Input; using Dragablz; -using DynamicData; using DynamicData.Aggregation; using DynamicData.Binding; using Microsoft.Win32; using TailBlazer.Domain.Infrastructure; using TailBlazer.Infrastucture; using System.Reactive.Concurrency; -using TailBlazer.Domain.FileHandling; namespace TailBlazer.Views { public class WindowViewModel: AbstractNotifyPropertyChanged, IDisposable { + private readonly ILogger _logger; private readonly IWindowsController _windowsController; - private readonly IRecentFiles _recentFiles; private readonly ISchedulerProvider _schedulerProvider; private readonly IObjectProvider _objectProvider; private readonly IDisposable _cleanUp; private ViewContainer _selected; private bool _isEmpty; + private bool _menuIsOpen; public ObservableCollection Views { get; } = new ObservableCollection(); - public ReadOnlyObservableCollection RecentFiles { get; } - + public RecentFilesViewModel RecentFiles { get; } + public IInterTabClient InterTabClient { get; } public ICommand OpenFileCommand { get; } public Command ShowInGitHubCommand { get; } @@ -46,12 +45,12 @@ public WindowViewModel(IObjectProvider objectProvider, IWindowFactory windowFactory, ILogger logger, IWindowsController windowsController, - IRecentFiles recentFiles, - ISchedulerProvider schedulerProvider ) + RecentFilesViewModel recentFilesViewModel, + ISchedulerProvider schedulerProvider) { _logger = logger; _windowsController = windowsController; - _recentFiles = recentFiles; + RecentFiles = recentFilesViewModel; _schedulerProvider = schedulerProvider; _objectProvider = objectProvider; InterTabClient = new InterTabClient(windowFactory); @@ -68,22 +67,19 @@ public WindowViewModel(IObjectProvider objectProvider, .Select(count=>count==0) .Subscribe(isEmpty=> IsEmpty = isEmpty); + var openRecent = recentFilesViewModel.OpenFileRequest + .Subscribe(file => + { + MenuIsOpen = false; + OpenFile(file); + }); - //move this out into it's own view model + create proxy so we can order - //and timestamp etc - //ReadOnlyObservableCollection data; - //var recentLoader = recentFiles.Items - // .Connect() - // .ObserveOn(schedulerProvider.MainThread) - // .Bind(out data) - // .Subscribe(); - // RecentFiles = data; - - _cleanUp = new CompositeDisposable(//recentLoader, + _cleanUp = new CompositeDisposable(recentFilesViewModel, isEmptyChecker, fileDropped, DropMonitor, + openRecent, Disposable.Create(() => { Views.Select(vc => vc.Content) @@ -114,7 +110,7 @@ public void OpenFile(FileInfo file) { _logger.Info($"Attempting to open '{file.FullName}'"); - _recentFiles.Register(file); + RecentFiles.Add(file); //1. resolve TailViewModel var factory = _objectProvider.Get(); @@ -170,6 +166,13 @@ public bool IsEmpty set { SetAndRaise(ref _isEmpty, value); } } + public bool MenuIsOpen + { + get { return _menuIsOpen; } + set { SetAndRaise(ref _menuIsOpen, value); } + } + + public void Dispose() { _cleanUp.Dispose();