From 81679097812bc339dff956ca16376a4f9371e60a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20Kov=C3=A1cs?= Date: Tue, 24 May 2022 17:02:36 +0200 Subject: [PATCH] Tab restore --- .../Models/IApplicationSettings.cs | 7 + .../Services/IStartupHandler.cs | 1 + .../Persistence/ITabPersistenceService.cs | 6 + .../ViewModels/ITabViewModel.cs | 2 + .../Models/ApplicationSettings.cs | 54 +++++ .../Persistence/TabPersistenceService.cs | 215 ++++++++++++++++++ src/AppCommon/FileTime.App.Core/Startup.cs | 6 +- ...faultIdentifiableCommandHandlerRegister.cs | 2 + .../ViewModels/TabViewModel.cs | 7 +- .../DependencyInjection.cs | 9 +- .../Avalonia/FileTime.GuiApp.App/Startup.cs | 5 +- .../Services/LifecycleService.cs | 12 +- .../Services/RootDriveInfoService.cs | 4 +- .../ViewModels/MainWindowViewModel.cs | 11 +- .../FileTime.GuiApp/Views/MainWindow.axaml | 1 + .../FileTime.GuiApp/Views/MainWindow.axaml.cs | 8 +- 16 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 src/AppCommon/FileTime.App.Core.Abstraction/Models/IApplicationSettings.cs create mode 100644 src/AppCommon/FileTime.App.Core.Abstraction/Services/Persistence/ITabPersistenceService.cs create mode 100644 src/AppCommon/FileTime.App.Core/Models/ApplicationSettings.cs create mode 100644 src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/Models/IApplicationSettings.cs b/src/AppCommon/FileTime.App.Core.Abstraction/Models/IApplicationSettings.cs new file mode 100644 index 0000000..dc212ef --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/Models/IApplicationSettings.cs @@ -0,0 +1,7 @@ +namespace FileTime.App.Core.Models; + +public interface IApplicationSettings +{ + string AppDataRoot { get; } + string EnvironmentName { get; } +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/Services/IStartupHandler.cs b/src/AppCommon/FileTime.App.Core.Abstraction/Services/IStartupHandler.cs index d649307..45683c3 100644 --- a/src/AppCommon/FileTime.App.Core.Abstraction/Services/IStartupHandler.cs +++ b/src/AppCommon/FileTime.App.Core.Abstraction/Services/IStartupHandler.cs @@ -2,4 +2,5 @@ namespace FileTime.App.Core.Services; public interface IStartupHandler { + Task InitAsync(); } \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/Services/Persistence/ITabPersistenceService.cs b/src/AppCommon/FileTime.App.Core.Abstraction/Services/Persistence/ITabPersistenceService.cs new file mode 100644 index 0000000..34ea51f --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/Services/Persistence/ITabPersistenceService.cs @@ -0,0 +1,6 @@ +namespace FileTime.App.Core.Services.Persistence; + +public interface ITabPersistenceService : IStartupHandler, IExitHandler +{ + void SaveStates(); +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/ViewModels/ITabViewModel.cs b/src/AppCommon/FileTime.App.Core.Abstraction/ViewModels/ITabViewModel.cs index c794826..2ed1b2d 100644 --- a/src/AppCommon/FileTime.App.Core.Abstraction/ViewModels/ITabViewModel.cs +++ b/src/AppCommon/FileTime.App.Core.Abstraction/ViewModels/ITabViewModel.cs @@ -24,6 +24,8 @@ public interface ITabViewModel : IInitable, IDisposable IObservable?> CurrentItemsCollectionObservable { get; } IObservable?> ParentsChildrenCollectionObservable { get; } IObservable?> SelectedsChildrenCollectionObservable { get; } + IContainer? CachedCurrentLocation { get; } + void ClearMarkedItems(); void RemoveMarkedItem(FullName fullName); void AddMarkedItem(FullName fullName); diff --git a/src/AppCommon/FileTime.App.Core/Models/ApplicationSettings.cs b/src/AppCommon/FileTime.App.Core/Models/ApplicationSettings.cs new file mode 100644 index 0000000..44f8875 --- /dev/null +++ b/src/AppCommon/FileTime.App.Core/Models/ApplicationSettings.cs @@ -0,0 +1,54 @@ +using System.Reflection; + +namespace FileTime.App.Core.Models; + +public class ApplicationSettings : IApplicationSettings +{ + public string AppDataRoot { get; private set; } = null!; + public string EnvironmentName { get; private set; } = null!; + + public ApplicationSettings() + { +#if DEBUG + InitDebugSettings(); +#else + InitReleaseSettings(); +#endif + } + + private void InitDebugSettings() + { + EnvironmentName = "Development"; + + AppDataRoot = Path.Combine(Environment.CurrentDirectory, "appdata"); + } + + private void InitReleaseSettings() + { + EnvironmentName = "Release"; + + var possibleDataRootsPaths = new List() + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "FileTime"), + Path.Combine(Assembly.GetEntryAssembly()?.Location ?? ".", "fallbackDataRoot") + }; + + string? appDataRoot = null; + foreach (var possibleAppDataRoot in possibleDataRootsPaths) + { + try + { + var appDataRootDirectory = new DirectoryInfo(possibleAppDataRoot); + if (!appDataRootDirectory.Exists) appDataRootDirectory.Create(); + + appDataRoot = possibleAppDataRoot; + break; + } + catch + { + } + } + + AppDataRoot = appDataRoot ?? throw new UnauthorizedAccessException(); + } +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs b/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs new file mode 100644 index 0000000..8a64f36 --- /dev/null +++ b/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs @@ -0,0 +1,215 @@ +using System.Reactive.Linq; +using System.Text.Json; +using FileTime.App.Core.Models; +using FileTime.App.Core.ViewModels; +using FileTime.Core.Models; +using FileTime.Core.Services; +using FileTime.Core.Timeline; +using FileTime.Providers.Local; +using InitableService; +using Microsoft.Extensions.Logging; + +namespace FileTime.App.Core.Services.Persistence; + +public class TabPersistenceService : ITabPersistenceService +{ + private readonly IAppState _appState; + private readonly ILogger _logger; + + private class PersistenceRoot + { + public TabStates? TabStates { get; set; } + } + + private class TabStates + { + public List? Tabs { get; set; } + public int? ActiveTabNumber { get; set; } + } + + private class TabState + { + public string? Path { get; set; } + public int Number { get; set; } + + public TabState() + { + } + + public TabState(FullName path, int number) + { + Path = path.Path; + Number = number; + } + } + + private readonly string _settingsPath; + private readonly JsonSerializerOptions _jsonOptions; + private readonly ITimelessContentProvider _timelessContentProvider; + private readonly IServiceProvider _serviceProvider; + private readonly ILocalContentProvider _localContentProvider; + + public TabPersistenceService( + IApplicationSettings applicationSettings, + IAppState appState, + ITimelessContentProvider timelessContentProvider, + IServiceProvider serviceProvider, + ILocalContentProvider localContentProvider, + ILogger logger) + { + _appState = appState; + _logger = logger; + _settingsPath = Path.Combine(applicationSettings.AppDataRoot, "savedState.json"); + _timelessContentProvider = timelessContentProvider; + _serviceProvider = serviceProvider; + _localContentProvider = localContentProvider; + + _jsonOptions = new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + } + + public Task ExitAsync() + { + SaveStates(); + + return Task.CompletedTask; + } + + private async Task LoadStatesAsync() + { + if (!File.Exists(_settingsPath)) return; + + try + { + await using var stateReader = File.OpenRead(_settingsPath); + var state = await JsonSerializer.DeserializeAsync(stateReader); + if (state != null) + { + await RestoreTabs(state.TabStates); + } + } + catch (Exception e) + { + _logger.LogError(e, "Unknown exception while restoring app state"); + } + } + private async Task RestoreTabs(TabStates? tabStates) + { + if (tabStates == null + || tabStates.Tabs == null) + { + CreateEmptyTab(); + return; + } + + try + { + + foreach (var tab in tabStates.Tabs) + { + try + { + if (tab.Path == null) continue; + + IContainer? container = null; + var path = new FullName(tab.Path); + while (true) + { + try + { + var pathItem = await _timelessContentProvider.GetItemByFullNameAsync(path, PointInTime.Present); + + container = pathItem switch + { + IContainer c => c, + IElement e => e.Parent?.ResolveAsync() as IContainer, + _ => null + }; + break; + } + catch + { + path = path.GetParent(); + if (path == null) + { + throw new Exception($"Could not find an initializable path along {tab.Path}"); + } + } + } + + if (container == null) continue; + + var tabToLoad = _serviceProvider.GetInitableResolver(container) + .GetRequiredService(); + var tabViewModel = _serviceProvider.GetInitableResolver(tabToLoad, tab.Number).GetRequiredService(); + + _appState.AddTab(tabViewModel); + } + catch (Exception e) + { + _logger.LogError(e, "Unkown exception while restoring tab. {TabState}", JsonSerializer.Serialize(tab, _jsonOptions)); + } + } + } + catch (Exception e) + { + _logger.LogError(e, "Unkown exception while restoring tabs."); + } + + if (_appState.Tabs.Count == 0) + { + CreateEmptyTab(); + } + else + { + var tabToActivate = _appState.Tabs.FirstOrDefault(t => t.TabNumber == tabStates.ActiveTabNumber); + if (tabToActivate is not null) _appState.SetSelectedTab(tabToActivate); + } + + void CreateEmptyTab() + { + var tab = _serviceProvider.GetInitableResolver(_localContentProvider) + .GetRequiredService(); + var tabViewModel = _serviceProvider.GetInitableResolver(tab, 1).GetRequiredService(); + + _appState.AddTab(tabViewModel); + } + } + + public void SaveStates() + { + var state = new PersistenceRoot + { + TabStates = SerializeTabStates() + }; + var settingsDirectory = new DirectoryInfo(string.Join(Path.DirectorySeparatorChar, _settingsPath.Split(Path.DirectorySeparatorChar)[0..^1])); + if (!settingsDirectory.Exists) settingsDirectory.Create(); + var serializedData = JsonSerializer.Serialize(state, _jsonOptions); + File.WriteAllText(_settingsPath, serializedData); + } + + private TabStates SerializeTabStates() + { + var tabStates = new List(); + foreach (var tab in _appState.Tabs) + { + var currentLocation = tab.CachedCurrentLocation; + if (currentLocation is null) continue; + tabStates.Add(new TabState(currentLocation.FullName!, tab.TabNumber)); + } + + return new TabStates() + { + Tabs = tabStates, + ActiveTabNumber = _appState.CurrentSelectedTab?.TabNumber + }; + } + + public async Task InitAsync() + { + await LoadStatesAsync(); + } +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core/Startup.cs b/src/AppCommon/FileTime.App.Core/Startup.cs index ae786b8..3f28678 100644 --- a/src/AppCommon/FileTime.App.Core/Startup.cs +++ b/src/AppCommon/FileTime.App.Core/Startup.cs @@ -22,9 +22,11 @@ public static class Startup serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); - return serviceCollection.AddCommandHandlers(); + + return serviceCollection + .AddCommandHandlers() + .AddSingleton(); } private static IServiceCollection AddCommandHandlers(this IServiceCollection serviceCollection) diff --git a/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs b/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs index 7afbeb1..192109a 100644 --- a/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs +++ b/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs @@ -37,6 +37,8 @@ public class DefaultIdentifiableCommandHandlerRegister : IStartupHandler AddUserCommand(SwitchToTabCommand.SwitchToTab8); } + public Task InitAsync() => Task.CompletedTask; + private void AddUserCommand(IIdentifiableUserCommand command) => _service.AddIdentifiableUserCommandFactory(command.UserCommandID, () => command); } \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core/ViewModels/TabViewModel.cs b/src/AppCommon/FileTime.App.Core/ViewModels/TabViewModel.cs index b912572..1931107 100644 --- a/src/AppCommon/FileTime.App.Core/ViewModels/TabViewModel.cs +++ b/src/AppCommon/FileTime.App.Core/ViewModels/TabViewModel.cs @@ -43,7 +43,8 @@ public partial class TabViewModel : ITabViewModel null!; public IObservable?> - SelectedsChildrenCollectionObservable { get; private set; } = null!; + SelectedsChildrenCollectionObservable + { get; private set; } = null!; [Property] private BindedCollection? _currentItemsCollection; @@ -51,6 +52,8 @@ public partial class TabViewModel : ITabViewModel [Property] private BindedCollection? _selectedsChildrenCollection; + public IContainer? CachedCurrentLocation { get; private set; } + public TabViewModel( IServiceProvider serviceProvider, IItemNameConverterService itemNameConverterService, @@ -74,6 +77,8 @@ public partial class TabViewModel : ITabViewModel tab.AddToDisposables(_disposables); CurrentLocation = tab.CurrentLocation.AsObservable(); + CurrentLocation.Subscribe(l => CachedCurrentLocation = l).AddToDisposables(_disposables); + CurrentItems = tab.CurrentItems .Select(items => items?.Transform(i => MapItemToViewModel(i, ItemViewModelType.Main))) /*.ObserveOn(_rxSchedulerService.GetWorkerScheduler()) diff --git a/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs b/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs index 7562c32..9c214e8 100644 --- a/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs +++ b/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs @@ -1,4 +1,7 @@ using FileTime.App.Core; +using FileTime.App.Core.Models; +using FileTime.App.Core.Services; +using FileTime.App.Core.Services.Persistence; using FileTime.Core.Command; using FileTime.Core.Command.CreateContainer; using FileTime.Core.Command.CreateElement; @@ -21,8 +24,12 @@ public static class DependencyInjection serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(); + serviceCollection.TryAddTransient(); + serviceCollection.AddSingleton(sp => sp.GetRequiredService()); + serviceCollection.AddSingleton(sp => sp.GetRequiredService()); return serviceCollection .AddCoreAppServices() diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Startup.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Startup.cs index 485c213..fc0d24e 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Startup.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Startup.cs @@ -35,13 +35,14 @@ public static class Startup serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(s => s.GetRequiredService()); - return serviceCollection; + + return serviceCollection + .AddSingleton(); } internal static IServiceCollection RegisterLogging(this IServiceCollection serviceCollection) diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Services/LifecycleService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/Services/LifecycleService.cs index 6ece91c..ae1be7b 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Services/LifecycleService.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Services/LifecycleService.cs @@ -5,13 +5,23 @@ namespace FileTime.GuiApp.Services; public class LifecycleService { private readonly IEnumerable _exitHandlers; + private readonly IEnumerable _startupHandlers; public LifecycleService(IEnumerable startupHandlers, IEnumerable exitHandlers) { _exitHandlers = exitHandlers; + _startupHandlers = startupHandlers; } - public async Task Exit() + public async Task InitStartupHandlersAsync() + { + foreach (var startupHandler in _startupHandlers) + { + await startupHandler.InitAsync(); + } + } + + public async Task ExitAsync() { foreach (var exitHandler in _exitHandlers) { diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Services/RootDriveInfoService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/Services/RootDriveInfoService.cs index 28c4c77..8953d31 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Services/RootDriveInfoService.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Services/RootDriveInfoService.cs @@ -38,7 +38,7 @@ public class RootDriveInfoService : IStartupHandler { var containerPath = localContentProvider.GetNativePath(i.Path).Path; var drivePath = d.Name.TrimEnd(Path.DirectorySeparatorChar); - return containerPath == drivePath + return containerPath == drivePath || (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && containerPath == "/" && d.Name == "/"); }))) .Filter(t => t.Drive is not null); @@ -68,4 +68,6 @@ public class RootDriveInfoService : IStartupHandler _rootDrives.AddRange(drives); } } + + public Task InitAsync() => Task.CompletedTask; } \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/ViewModels/MainWindowViewModel.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/ViewModels/MainWindowViewModel.cs index a5436e5..b3b07d7 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/ViewModels/MainWindowViewModel.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/ViewModels/MainWindowViewModel.cs @@ -50,14 +50,16 @@ public partial class MainWindowViewModel : IMainWindowViewModelBase Title = "FileTime " + versionString; //TODO: refactor - if (AppState.Tabs.Count == 0) + /*if (AppState.Tabs.Count == 0) { var tab = _serviceProvider.GetInitableResolver(_localContentProvider) .GetRequiredService(); var tabViewModel = _serviceProvider.GetInitableResolver(tab, 1).GetRequiredService(); _appState.AddTab(tabViewModel); - } + }*/ + + _lifecycleService.InitStartupHandlersAsync().Wait(); } public void ProcessKeyDown(Key key, KeyModifiers keyModifiers, Action setHandled) @@ -72,4 +74,9 @@ public partial class MainWindowViewModel : IMainWindowViewModelBase await UserCommandHandlerService.HandleCommandAsync( new OpenContainerCommand(new AbsolutePath(_timelessContentProvider, resolvedContainer))); } + + public async Task OnExit() + { + await _lifecycleService.ExitAsync(); + } } \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml index b3f5d20..b2230a4 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml @@ -22,6 +22,7 @@ Icon="/Assets/filetime.ico" InputElement.KeyDown="OnKeyDown" Opened="OnWindowOpened" + Closed="OnWindowClosed" TransparencyLevelHint="Blur" mc:Ignorable="d"> diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml.cs index 847cadd..f1afe81 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml.cs @@ -82,7 +82,7 @@ public partial class MainWindow : Window && sender is StyledElement control) { FullName? path = null; - if (control.DataContext is IHaveFullPath { Path: { } } hasFullPath) + if (control.DataContext is IHaveFullPath {Path: { }} hasFullPath) { path = hasFullPath.Path; } @@ -108,4 +108,10 @@ public partial class MainWindow : Window e.Handled = true; } } + + private void OnWindowClosed(object? sender, EventArgs e) + { + var vm = ViewModel; + Task.Run(() => vm?.OnExit()).Wait(); + } } \ No newline at end of file