diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/Services/IModalService.cs b/src/AppCommon/FileTime.App.Core.Abstraction/Services/IModalService.cs index ae7f9f5..4682a4c 100644 --- a/src/AppCommon/FileTime.App.Core.Abstraction/Services/IModalService.cs +++ b/src/AppCommon/FileTime.App.Core.Abstraction/Services/IModalService.cs @@ -9,4 +9,5 @@ public interface IModalService void OpenModal(IModalViewModel modalToOpen); void CloseModal(IModalViewModel modalToClose); + T OpenModal() where T : IModalViewModel; } \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/GoByFrequencyCommand.cs b/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/GoByFrequencyCommand.cs new file mode 100644 index 0000000..f8ae600 --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/GoByFrequencyCommand.cs @@ -0,0 +1,14 @@ +namespace FileTime.App.Core.UserCommand; + +public class GoByFrequencyCommand : IIdentifiableUserCommand +{ + public const string CommandName = "go_by_frequency"; + + public static GoByFrequencyCommand Instance { get; } = new(); + + private GoByFrequencyCommand() + { + } + + public string UserCommandID => CommandName; +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core/FileTime.App.Core.csproj b/src/AppCommon/FileTime.App.Core/FileTime.App.Core.csproj index 6b4c926..1241f1b 100644 --- a/src/AppCommon/FileTime.App.Core/FileTime.App.Core.csproj +++ b/src/AppCommon/FileTime.App.Core/FileTime.App.Core.csproj @@ -25,6 +25,7 @@ + diff --git a/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs b/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs index 4c9e9fe..62c9c9a 100644 --- a/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs +++ b/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs @@ -63,7 +63,7 @@ public class TabPersistenceService : ITabPersistenceService _serviceProvider = serviceProvider; _localContentProvider = localContentProvider; - _jsonOptions = new JsonSerializerOptions() + _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, WriteIndented = true diff --git a/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/NavigationUserCommandHandlerService.cs b/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/NavigationUserCommandHandlerService.cs index 56e582d..55c57f1 100644 --- a/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/NavigationUserCommandHandlerService.cs +++ b/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/NavigationUserCommandHandlerService.cs @@ -2,6 +2,7 @@ using FileTime.App.Core.Extensions; using FileTime.App.Core.Models.Enums; using FileTime.App.Core.UserCommand; using FileTime.App.Core.ViewModels; +using FileTime.App.FrequencyNavigation.Services; using FileTime.Core.Interactions; using FileTime.Core.Models; using FileTime.Core.Services; @@ -20,6 +21,7 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase private readonly IUserCommandHandlerService _userCommandHandlerService; private readonly ITimelessContentProvider _timelessContentProvider; private readonly IUserCommunicationService _userCommunicationService; + private readonly IFrequencyNavigationService _frequencyNavigationService; private ITabViewModel? _selectedTab; private IContainer? _currentLocation; private IItemViewModel? _currentSelectedItem; @@ -32,7 +34,8 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase ILocalContentProvider localContentProvider, IUserCommandHandlerService userCommandHandlerService, ITimelessContentProvider timelessContentProvider, - IUserCommunicationService userCommunicationService) : base(appState) + IUserCommunicationService userCommunicationService, + IFrequencyNavigationService frequencyNavigationService) : base(appState) { _appState = appState; _serviceProvider = serviceProvider; @@ -40,6 +43,7 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase _userCommandHandlerService = userCommandHandlerService; _timelessContentProvider = timelessContentProvider; _userCommunicationService = userCommunicationService; + _frequencyNavigationService = frequencyNavigationService; SaveSelectedTab(t => _selectedTab = t); SaveCurrentSelectedItem(i => _currentSelectedItem = i); @@ -53,6 +57,7 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase new TypeUserCommandHandler(CloseTab), new TypeUserCommandHandler(EnterRapidTravel), new TypeUserCommandHandler(ExitRapidTravel), + new TypeUserCommandHandler(GoByFrequency), new TypeUserCommandHandler(GoToHome), new TypeUserCommandHandler(GoToPath), new TypeUserCommandHandler(GoToProvider), @@ -71,6 +76,12 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase }); } + private Task GoByFrequency() + { + _frequencyNavigationService.OpenNavigationWindow(); + return Task.CompletedTask; + } + private async Task GoToPath() { var pathInput = new TextInputElement("Path"); diff --git a/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs b/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs index 7771052..bb35875 100644 --- a/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs +++ b/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs @@ -20,6 +20,7 @@ public class DefaultIdentifiableCommandHandlerRegister : IStartupHandler AddUserCommand(DeleteCommand.SoftDelete); AddUserCommand(EnterRapidTravelCommand.Instance); AddUserCommand(ExitRapidTravelCommand.Instance); + AddUserCommand(GoByFrequencyCommand.Instance); AddUserCommand(GoToHomeCommand.Instance); AddUserCommand(GoToPathCommand.Instance); AddUserCommand(GoToProviderCommand.Instance); diff --git a/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs b/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs index 87387c7..9868890 100644 --- a/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs +++ b/src/AppCommon/FileTime.App.DependencyInjection/DependencyInjection.cs @@ -35,6 +35,7 @@ public static class DependencyInjection serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddTransient(); + serviceCollection.TryAddSingleton(); serviceCollection.AddSingleton(sp => sp.GetRequiredService()); serviceCollection.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/FileTime.App.FrequencyNavigation.Abstractions.csproj b/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/FileTime.App.FrequencyNavigation.Abstractions.csproj new file mode 100644 index 0000000..1e26cb7 --- /dev/null +++ b/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/FileTime.App.FrequencyNavigation.Abstractions.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + FileTime.App.FrequencyNavigation + + + + + + + diff --git a/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/Services/IFrequencyNavigationService.cs b/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/Services/IFrequencyNavigationService.cs new file mode 100644 index 0000000..5d89e73 --- /dev/null +++ b/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/Services/IFrequencyNavigationService.cs @@ -0,0 +1,12 @@ +using FileTime.App.FrequencyNavigation.ViewModels; + +namespace FileTime.App.FrequencyNavigation.Services; + +public interface IFrequencyNavigationService +{ + IObservable ShowWindow { get; } + IFrequencyNavigationViewModel? CurrentModal { get; } + void OpenNavigationWindow(); + void CloseNavigationWindow(); + IList GetMatchingContainers(string searchText); +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/ViewModels/IFrequencyNavigationViewModel.cs b/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/ViewModels/IFrequencyNavigationViewModel.cs new file mode 100644 index 0000000..a20f81a --- /dev/null +++ b/src/AppCommon/FileTime.App.FrequencyNavigation.Abstractions/ViewModels/IFrequencyNavigationViewModel.cs @@ -0,0 +1,12 @@ +using FileTime.App.Core.ViewModels; + +namespace FileTime.App.FrequencyNavigation.ViewModels; + +public interface IFrequencyNavigationViewModel : IModalViewModel +{ + IObservable ShowWindow { get; } + List FilteredMatches { get; } + string SearchText { get; set; } + string SelectedItem { get; set; } + void Close(); +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.FrequencyNavigation/FileTime.App.FrequencyNavigation.csproj b/src/AppCommon/FileTime.App.FrequencyNavigation/FileTime.App.FrequencyNavigation.csproj new file mode 100644 index 0000000..c019b09 --- /dev/null +++ b/src/AppCommon/FileTime.App.FrequencyNavigation/FileTime.App.FrequencyNavigation.csproj @@ -0,0 +1,25 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/AppCommon/FileTime.App.FrequencyNavigation/Models/ContainerFrequencyData.cs b/src/AppCommon/FileTime.App.FrequencyNavigation/Models/ContainerFrequencyData.cs new file mode 100644 index 0000000..884d324 --- /dev/null +++ b/src/AppCommon/FileTime.App.FrequencyNavigation/Models/ContainerFrequencyData.cs @@ -0,0 +1,7 @@ +namespace FileTime.App.FrequencyNavigation.Models; + +public class ContainerFrequencyData +{ + public int Score { get; set; } = 1; + public DateTime LastAccessed { get; set; } = DateTime.Now; +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.FrequencyNavigation/Services/FrequencyNavigationService.cs b/src/AppCommon/FileTime.App.FrequencyNavigation/Services/FrequencyNavigationService.cs new file mode 100644 index 0000000..2038c35 --- /dev/null +++ b/src/AppCommon/FileTime.App.FrequencyNavigation/Services/FrequencyNavigationService.cs @@ -0,0 +1,194 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text.Json; +using FileTime.App.Core.Models; +using FileTime.App.Core.Services; +using FileTime.App.FrequencyNavigation.Models; +using FileTime.App.FrequencyNavigation.ViewModels; +using FileTime.Core.Models; +using FileTime.Core.Services; +using Microsoft.Extensions.Logging; +using PropertyChanged.SourceGenerator; + +namespace FileTime.App.FrequencyNavigation.Services; + +public partial class FrequencyNavigationService : IFrequencyNavigationService, IStartupHandler, IExitHandler +{ + private const int MaxAge = 10_000; + + private DateTime _lastSave = DateTime.Now; + private readonly ILogger _logger; + private readonly IModalService _modalService; + private readonly SemaphoreSlim _saveLock = new(1); + private Dictionary _containerScores = new(); + private readonly BehaviorSubject _showWindow = new(false); + private readonly string _dbPath; + [Notify] IFrequencyNavigationViewModel? _currentModal; + IObservable IFrequencyNavigationService.ShowWindow => _showWindow.AsObservable(); + + public FrequencyNavigationService( + ITabEvents tabEvents, + IApplicationSettings applicationSettings, + ILogger logger, + IModalService modalService) + { + _logger = logger; + _modalService = modalService; + _dbPath = Path.Combine(applicationSettings.AppDataRoot, "frequencyNavigationScores.json"); + tabEvents.LocationChanged += OnTabLocationChanged; + } + + void OnTabLocationChanged(object? sender, TabLocationChanged e) + { + IncreaseContainerScore(e.Location); + } + + public void OpenNavigationWindow() + { + _showWindow.OnNext(true); + CurrentModal = _modalService.OpenModal(); + } + + public void CloseNavigationWindow() + { + _showWindow.OnNext(false); + if (_currentModal is not null) + { + _modalService.CloseModal(_currentModal); + CurrentModal = null; + } + } + + private async void IncreaseContainerScore(FullName containerName) + { + await _saveLock.WaitAsync(); + try + { + var containerNameString = containerName.Path; + if (_containerScores.ContainsKey(containerNameString)) + { + _containerScores[containerNameString].Score++; + _containerScores[containerNameString].LastAccessed = DateTime.Now; + } + else + { + _containerScores.Add(containerNameString, new ContainerFrequencyData()); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error increasing container score"); + } + finally + { + _saveLock.Release(); + } + + try + { + if (TryAgeContainerScores() || DateTime.Now - _lastSave > TimeSpan.FromMinutes(5)) + { + } + + await SaveStateAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Error aging container scores"); + } + } + + private bool TryAgeContainerScores() + { + if (_containerScores.Select(c => c.Value.Score).Sum() < MaxAge) + return false; + + AgeContainerScores(); + return true; + } + + private void AgeContainerScores() + { + var now = DateTime.Now; + var itemsToRemove = new List(); + foreach (var container in _containerScores) + { + var newScore = (int) Math.Floor(container.Value.Score * 0.9); + if (newScore > 0) + { + container.Value.Score = newScore; + } + else + { + itemsToRemove.Add(container.Key); + } + } + + foreach (var itemToRemove in itemsToRemove) + { + _containerScores.Remove(itemToRemove); + } + } + + public IList GetMatchingContainers(string searchText) + { + if (string.IsNullOrWhiteSpace(searchText)) + return new List(); + + _saveLock.Wait(); + var matchingContainers = _containerScores + .Where(c => c.Key.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + .OrderBy(c => GetWeightedScore(c.Value.Score, c.Value.LastAccessed)) + .Select(c => c.Key) + .ToList(); + + _saveLock.Release(); + return matchingContainers; + } + + private int GetWeightedScore(int score, DateTime lastAccess) + { + var now = DateTime.Now; + var timeSinceLastAccess = now - lastAccess; + return timeSinceLastAccess.TotalHours switch + { + < 1 => score *= 4, + < 24 => score *= 2, + < 168 => score /= 2, + _ => score /= 4 + }; + } + + public async Task InitAsync() + { + await LoadStateAsync(); + } + + private async Task LoadStateAsync() + { + if (!File.Exists(_dbPath)) + return; + + await _saveLock.WaitAsync(); + await using var dbStream = File.OpenRead(_dbPath); + var containerScores = await JsonSerializer.DeserializeAsync>(dbStream); + if (containerScores is null) return; + + _containerScores = containerScores; + _saveLock.Release(); + } + + public async Task ExitAsync() + { + await SaveStateAsync(); + } + + private async Task SaveStateAsync() + { + await _saveLock.WaitAsync(); + _lastSave = DateTime.Now; + await using var dbStream = File.OpenWrite(_dbPath); + await JsonSerializer.SerializeAsync(dbStream, _containerScores); + _saveLock.Release(); + } +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.FrequencyNavigation/Startup.cs b/src/AppCommon/FileTime.App.FrequencyNavigation/Startup.cs new file mode 100644 index 0000000..8330d72 --- /dev/null +++ b/src/AppCommon/FileTime.App.FrequencyNavigation/Startup.cs @@ -0,0 +1,20 @@ +using FileTime.App.Core.Services; +using FileTime.App.FrequencyNavigation.Services; +using FileTime.App.FrequencyNavigation.ViewModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace FileTime.App.FrequencyNavigation; + +public static class Startup +{ + public static IServiceCollection AddFrequencyNavigation(this IServiceCollection services) + { + services.TryAddTransient(); + services.AddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.FrequencyNavigation/ViewModels/FrequencyNavigationViewModel.cs b/src/AppCommon/FileTime.App.FrequencyNavigation/ViewModels/FrequencyNavigationViewModel.cs new file mode 100644 index 0000000..fb242ba --- /dev/null +++ b/src/AppCommon/FileTime.App.FrequencyNavigation/ViewModels/FrequencyNavigationViewModel.cs @@ -0,0 +1,47 @@ +using FileTime.App.Core.ViewModels; +using FileTime.App.FrequencyNavigation.Services; +using MvvmGen; + +namespace FileTime.App.FrequencyNavigation.ViewModels; + +[ViewModel] +[Inject(typeof(IFrequencyNavigationService), "_frequencyNavigationService")] +public partial class FrequencyNavigationViewModel : IFrequencyNavigationViewModel +{ + private string _searchText; + + [Property] private IObservable _showWindow; + [Property] private List _filteredMatches; + [Property] private string _selectedItem; + + public string SearchText + { + get => _searchText; + set + { + if (_searchText == value) return; + + _searchText = value; + OnPropertyChanged(); + + UpdateFilteredMatches(); + } + } + + public void Close() + { + _frequencyNavigationService.CloseNavigationWindow(); + } + + partial void OnInitialize() + { + _showWindow = _frequencyNavigationService.ShowWindow; + } + + private void UpdateFilteredMatches() + { + FilteredMatches = new List(_frequencyNavigationService.GetMatchingContainers(_searchText)); + } + + string IModalViewModel.Name => "FrequencyNavigation"; +} \ No newline at end of file diff --git a/src/Core/FileTime.Core.Abstraction/Models/IItem.cs b/src/Core/FileTime.Core.Abstraction/Models/IItem.cs index 3bff277..43519df 100644 --- a/src/Core/FileTime.Core.Abstraction/Models/IItem.cs +++ b/src/Core/FileTime.Core.Abstraction/Models/IItem.cs @@ -1,4 +1,5 @@ using System.Reactive.Linq; +using DynamicData; using FileTime.Core.ContentAccess; using FileTime.Core.Enums; using FileTime.Core.Timeline; @@ -21,7 +22,7 @@ public interface IItem string? Attributes { get; } AbsolutePathType Type { get; } PointInTime PointInTime { get; } - IObservable> Exceptions { get; } + IObservable> Exceptions { get; } ReadOnlyExtensionCollection Extensions { get; } T? GetExtension() => (T?)Extensions.FirstOrDefault(i => i is T); diff --git a/src/Core/FileTime.Core.Abstraction/Models/TabLocationChanged.cs b/src/Core/FileTime.Core.Abstraction/Models/TabLocationChanged.cs new file mode 100644 index 0000000..c51892a --- /dev/null +++ b/src/Core/FileTime.Core.Abstraction/Models/TabLocationChanged.cs @@ -0,0 +1,15 @@ +using FileTime.Core.Services; + +namespace FileTime.Core.Models; + +public class TabLocationChanged : EventArgs +{ + public FullName Location { get; } + public ITab Tab { get; } + + public TabLocationChanged(FullName location, ITab tab) + { + Location = location; + Tab = tab; + } +} \ No newline at end of file diff --git a/src/Core/FileTime.Core.Abstraction/Services/ITabEvents.cs b/src/Core/FileTime.Core.Abstraction/Services/ITabEvents.cs new file mode 100644 index 0000000..f53bd8e --- /dev/null +++ b/src/Core/FileTime.Core.Abstraction/Services/ITabEvents.cs @@ -0,0 +1,9 @@ +using FileTime.Core.Models; + +namespace FileTime.Core.Services; + +public interface ITabEvents +{ + event EventHandler LocationChanged; + void OnLocationChanged(ITab tab, FullName location); +} \ No newline at end of file diff --git a/src/Core/FileTime.Core.ContentAccess/ContentProviderBase.cs b/src/Core/FileTime.Core.ContentAccess/ContentProviderBase.cs index 6fcdf2f..9b5d2d7 100644 --- a/src/Core/FileTime.Core.ContentAccess/ContentProviderBase.cs +++ b/src/Core/FileTime.Core.ContentAccess/ContentProviderBase.cs @@ -48,7 +48,8 @@ public abstract class ContentProviderBase : IContentProvider public AbsolutePathType Type => AbsolutePathType.Container; public PointInTime PointInTime { get; } = PointInTime.Eternal; - public IObservable> Exceptions => Observable.Return(Enumerable.Empty()); + protected SourceList Exceptions { get; } = new(); + IObservable> IItem.Exceptions => Exceptions.Connect(); ReadOnlyExtensionCollection IItem.Extensions => _extensions; diff --git a/src/Core/FileTime.Core.Models/Container.cs b/src/Core/FileTime.Core.Models/Container.cs index 436c518..ace5ff0 100644 --- a/src/Core/FileTime.Core.Models/Container.cs +++ b/src/Core/FileTime.Core.Models/Container.cs @@ -22,7 +22,7 @@ public record Container( IContentProvider Provider, bool AllowRecursiveDeletion, PointInTime PointInTime, - IObservable> Exceptions, + IObservable> Exceptions, ReadOnlyExtensionCollection Extensions, IObservable>?> Items) : IContainer { diff --git a/src/Core/FileTime.Core.Models/Element.cs b/src/Core/FileTime.Core.Models/Element.cs index 36b0710..5796694 100644 --- a/src/Core/FileTime.Core.Models/Element.cs +++ b/src/Core/FileTime.Core.Models/Element.cs @@ -1,3 +1,4 @@ +using DynamicData; using FileTime.Core.ContentAccess; using FileTime.Core.Enums; using FileTime.Core.Timeline; @@ -18,7 +19,7 @@ public record Element( string? Attributes, IContentProvider Provider, PointInTime PointInTime, - IObservable> Exceptions, + IObservable> Exceptions, ReadOnlyExtensionCollection Extensions) : IElement { public AbsolutePathType Type => AbsolutePathType.Element; diff --git a/src/Core/FileTime.Core.Services/Tab.cs b/src/Core/FileTime.Core.Services/Tab.cs index c478b1a..e8588e7 100644 --- a/src/Core/FileTime.Core.Services/Tab.cs +++ b/src/Core/FileTime.Core.Services/Tab.cs @@ -12,6 +12,7 @@ namespace FileTime.Core.Services; public class Tab : ITab { private readonly ITimelessContentProvider _timelessContentProvider; + private readonly ITabEvents _tabEvents; private readonly BehaviorSubject _currentLocation = new(null); private readonly BehaviorSubject _currentLocationForced = new(null); private readonly BehaviorSubject _currentSelectedItem = new(null); @@ -24,9 +25,10 @@ public class Tab : ITab public IObservable CurrentSelectedItem { get; } public FullName? LastDeepestSelectedPath { get; private set; } - public Tab(ITimelessContentProvider timelessContentProvider) + public Tab(ITimelessContentProvider timelessContentProvider, ITabEvents tabEvents) { _timelessContentProvider = timelessContentProvider; + _tabEvents = tabEvents; _currentPointInTime = null!; _timelessContentProvider.CurrentPointInTime.Subscribe(p => _currentPointInTime = p); @@ -60,7 +62,7 @@ public class Tab : ITab ), CurrentLocation .Where(c => c is null) - .Select(_ => (IObservable>?)null) + .Select(_ => (IObservable>?) null) ) .Publish(null) .RefCount(); @@ -137,8 +139,25 @@ public class Tab : ITab return newSelectedItem; } - public void SetCurrentLocation(IContainer newLocation) => _currentLocation.OnNext(newLocation); - public void ForceSetCurrentLocation(IContainer newLocation) => _currentLocationForced.OnNext(newLocation); + public void SetCurrentLocation(IContainer newLocation) + { + _currentLocation.OnNext(newLocation); + + if (newLocation.FullName != null) + { + _tabEvents.OnLocationChanged(this, newLocation.FullName); + } + } + + public void ForceSetCurrentLocation(IContainer newLocation) + { + _currentLocationForced.OnNext(newLocation); + + if (newLocation.FullName != null) + { + _tabEvents.OnLocationChanged(this, newLocation.FullName); + } + } public void SetSelectedItem(AbsolutePath newSelectedItem) => _currentSelectedItem.OnNext(newSelectedItem); diff --git a/src/Core/FileTime.Core.Services/TabEvents.cs b/src/Core/FileTime.Core.Services/TabEvents.cs new file mode 100644 index 0000000..1b2c050 --- /dev/null +++ b/src/Core/FileTime.Core.Services/TabEvents.cs @@ -0,0 +1,13 @@ +using FileTime.Core.Models; + +namespace FileTime.Core.Services; + +public class TabEvents : ITabEvents +{ + public event EventHandler LocationChanged; + + public void OnLocationChanged(ITab tab, FullName location) + { + LocationChanged?.Invoke(this, new TabLocationChanged(location, tab)); + } +} \ No newline at end of file diff --git a/src/FileTime.sln b/src/FileTime.sln index a13ed42..31054f1 100644 --- a/src/FileTime.sln +++ b/src/FileTime.sln @@ -63,6 +63,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileTime.GuiApp.Font", "Gui EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileTime.GuiApp.Font.Abstractions", "GuiApp\Avalonia\FileTime.GuiApp.Font.Abstractions\FileTime.GuiApp.Font.Abstractions.csproj", "{2D07F149-106B-4644-9586-D6218F78D868}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileTime.App.FrequencyNavigation", "AppCommon\FileTime.App.FrequencyNavigation\FileTime.App.FrequencyNavigation.csproj", "{253348AD-C9C0-4162-A2ED-C6FF8730B275}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileTime.App.FrequencyNavigation.Abstractions", "AppCommon\FileTime.App.FrequencyNavigation.Abstractions\FileTime.App.FrequencyNavigation.Abstractions.csproj", "{C1CA8B7E-F8E6-40AB-A45B-5EBEF6996290}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -157,6 +161,14 @@ Global {2D07F149-106B-4644-9586-D6218F78D868}.Debug|Any CPU.Build.0 = Debug|Any CPU {2D07F149-106B-4644-9586-D6218F78D868}.Release|Any CPU.ActiveCfg = Release|Any CPU {2D07F149-106B-4644-9586-D6218F78D868}.Release|Any CPU.Build.0 = Release|Any CPU + {253348AD-C9C0-4162-A2ED-C6FF8730B275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {253348AD-C9C0-4162-A2ED-C6FF8730B275}.Debug|Any CPU.Build.0 = Debug|Any CPU + {253348AD-C9C0-4162-A2ED-C6FF8730B275}.Release|Any CPU.ActiveCfg = Release|Any CPU + {253348AD-C9C0-4162-A2ED-C6FF8730B275}.Release|Any CPU.Build.0 = Release|Any CPU + {C1CA8B7E-F8E6-40AB-A45B-5EBEF6996290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1CA8B7E-F8E6-40AB-A45B-5EBEF6996290}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1CA8B7E-F8E6-40AB-A45B-5EBEF6996290}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1CA8B7E-F8E6-40AB-A45B-5EBEF6996290}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -185,6 +197,8 @@ Global {9B161766-A672-4D59-B591-C68907905158} = {3324D046-1E05-46B5-B1BA-82910D56B332} {767F3868-11D0-445D-9B86-F81C7FCEB6FA} = {01F231DE-4A65-435F-B4BB-77EE5221890C} {2D07F149-106B-4644-9586-D6218F78D868} = {01F231DE-4A65-435F-B4BB-77EE5221890C} + {253348AD-C9C0-4162-A2ED-C6FF8730B275} = {A5291117-3001-498B-AC8B-E14F71F72570} + {C1CA8B7E-F8E6-40AB-A45B-5EBEF6996290} = {A5291117-3001-498B-AC8B-E14F71F72570} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {859FB3DF-C60A-46B1-82E5-90274905D1EF} diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/Configuration/MainConfiguration.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/Configuration/MainConfiguration.cs index 13052b2..c2f099a 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/Configuration/MainConfiguration.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.Abstractions/Configuration/MainConfiguration.cs @@ -59,6 +59,7 @@ public static class MainConfiguration new(EnterRapidTravelCommand.CommandName,new KeyConfig(Key.OemQuestion, shift: true)), //new CommandBindingConfiguration(ConfigCommand.FindByName, new[] { Key.F, Key.N }), //new CommandBindingConfiguration(ConfigCommand.FindByNameRegex, new[] { Key.F, Key.R }), + new(GoByFrequencyCommand.CommandName, Key.Z), new(GoToHomeCommand.CommandName, new[] { Key.G, Key.H }), new(GoToPathCommand.CommandName, new KeyConfig(Key.L, ctrl: true)), new(GoToPathCommand.CommandName, new[] { Key.G, Key.P }), diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/App.axaml.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/App.axaml.cs index cd5b667..a4854fb 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/App.axaml.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/App.axaml.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using FileTime.App.DependencyInjection; +using FileTime.App.FrequencyNavigation; using FileTime.GuiApp.Font; using FileTime.GuiApp.ViewModels; using FileTime.GuiApp.Views; @@ -17,6 +18,7 @@ public partial class App : Application var configuration = Startup.CreateConfiguration(); DI.ServiceProvider = DependencyInjection .RegisterDefaultServices() + .AddFrequencyNavigation() .AddConfiguration(configuration) .ConfigureFont(configuration) .RegisterLogging() diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/FileTime.GuiApp.App.csproj b/src/GuiApp/Avalonia/FileTime.GuiApp.App/FileTime.GuiApp.App.csproj index fe69962..d107efb 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/FileTime.GuiApp.App.csproj +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/FileTime.GuiApp.App.csproj @@ -41,6 +41,7 @@ + diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/FileTime.GuiApp.csproj b/src/GuiApp/Avalonia/FileTime.GuiApp/FileTime.GuiApp.csproj index 72f088d..4827e25 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/FileTime.GuiApp.csproj +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/FileTime.GuiApp.csproj @@ -39,6 +39,7 @@ + diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Services/ModalService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/Services/ModalService.cs index 045acfc..724ef32 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Services/ModalService.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Services/ModalService.cs @@ -1,20 +1,31 @@ using DynamicData; using FileTime.App.Core.Services; using FileTime.App.Core.ViewModels; +using Microsoft.Extensions.DependencyInjection; namespace FileTime.GuiApp.Services; public class ModalService : IModalService { + private readonly IServiceProvider _serviceProvider; private readonly SourceList _openModals = new(); public IObservable> OpenModals { get; } - public ModalService() + public ModalService(IServiceProvider serviceProvider) { + _serviceProvider = serviceProvider; OpenModals = _openModals.Connect().StartWithEmpty(); } public void OpenModal(IModalViewModel modalToOpen) => _openModals.Add(modalToOpen); public void CloseModal(IModalViewModel modalToClose) => _openModals.Remove(modalToClose); + + public T OpenModal() where T : IModalViewModel + { + var modal = _serviceProvider.GetRequiredService(); + OpenModal(modal); + + return modal; + } } \ 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 2d1c238..cd4ea5c 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/ViewModels/MainWindowViewModel.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/ViewModels/MainWindowViewModel.cs @@ -3,6 +3,8 @@ using System.Reflection; using Avalonia.Input; using FileTime.App.Core.Services; using FileTime.App.Core.UserCommand; +using FileTime.App.FrequencyNavigation.Services; +using FileTime.App.FrequencyNavigation.ViewModels; using FileTime.Core.Models; using FileTime.Core.Timeline; using FileTime.GuiApp.Services; @@ -24,6 +26,7 @@ namespace FileTime.GuiApp.ViewModels; [Inject(typeof(IDialogService), PropertyAccessModifier = AccessModifier.Public)] [Inject(typeof(ITimelessContentProvider), PropertyName = "_timelessContentProvider")] [Inject(typeof(IFontService), "_fontService")] +[Inject(typeof(IFrequencyNavigationService), PropertyAccessModifier = AccessModifier.Public)] public partial class MainWindowViewModel : IMainWindowViewModelBase { public bool Loading => false; diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/FrequencyNavigation.axaml b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/FrequencyNavigation.axaml new file mode 100644 index 0000000..d6270cb --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/FrequencyNavigation.axaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/FrequencyNavigation.axaml.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/FrequencyNavigation.axaml.cs new file mode 100644 index 0000000..72701a0 --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/FrequencyNavigation.axaml.cs @@ -0,0 +1,30 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using FileTime.App.FrequencyNavigation.ViewModels; + +namespace FileTime.GuiApp.Views; + +public partial class FrequencyNavigation : UserControl +{ + public FrequencyNavigation() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void Search_OnKeyDown(object? sender, KeyEventArgs e) + { + if (DataContext is not IFrequencyNavigationViewModel viewModel) return; + + if (e.Key == Key.Escape) + { + viewModel.Close(); + } + } +} \ 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 4a9e8bf..e8fd8c8 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Views/MainWindow.axaml @@ -709,6 +709,15 @@ + + + diff --git a/src/Providers/FileTime.Providers.Local/LocalContentProvider.cs b/src/Providers/FileTime.Providers.Local/LocalContentProvider.cs index 303002e..ea12b84 100644 --- a/src/Providers/FileTime.Providers.Local/LocalContentProvider.cs +++ b/src/Providers/FileTime.Providers.Local/LocalContentProvider.cs @@ -76,11 +76,11 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo { if ((path?.Length ?? 0) == 0) { - return Task.FromResult((IItem)this); + return Task.FromResult((IItem) this); } else if (Directory.Exists(path)) { - return Task.FromResult((IItem)DirectoryToContainer( + return Task.FromResult((IItem) DirectoryToContainer( new DirectoryInfo(path!.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar), pointInTime, !itemInitializationSettings.SkipChildInitialization) @@ -88,7 +88,7 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo } else if (File.Exists(path)) { - return Task.FromResult((IItem)FileToElement(new FileInfo(path), pointInTime)); + return Task.FromResult((IItem) FileToElement(new FileInfo(path), pointInTime)); } var type = forceResolvePathType switch @@ -120,10 +120,10 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo return forceResolvePathType switch { AbsolutePathType.Container => Task.FromResult( - (IItem)CreateEmptyContainer( + (IItem) CreateEmptyContainer( nativePath, pointInTime, - Observable.Return(new List() { innerException }) + new List() {innerException} ) ), AbsolutePathType.Element => Task.FromResult(CreateEmptyElement(nativePath)), @@ -135,9 +135,14 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo private Container CreateEmptyContainer(NativePath nativePath, PointInTime pointInTime, - IObservable>? exceptions = null) + IEnumerable? initialExceptions = null) { - var nonNullExceptions = exceptions ?? Observable.Return(Enumerable.Empty()); + var exceptions = new SourceList(); + if (initialExceptions is not null) + { + exceptions.AddRange(initialExceptions); + } + var name = nativePath.Path.Split(Path.DirectorySeparatorChar).LastOrDefault() ?? "???"; var fullName = GetFullName(nativePath); @@ -166,7 +171,7 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo this, true, pointInTime, - nonNullExceptions, + exceptions.Connect(), new ExtensionCollection().AsReadOnly(), Observable.Return>?>(null) ); @@ -202,7 +207,7 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo pointInTime, parentFullName, AbsolutePathType.Container); - var exceptions = new BehaviorSubject>(Enumerable.Empty()); + var exceptions = new SourceList(); var children = new SourceCache(i => i.Path.Path); @@ -221,14 +226,14 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo this, true, pointInTime, - exceptions, + exceptions.Connect(), new ExtensionCollection().AsReadOnly(), //Observable.FromAsync(async () => await Task.Run(InitChildrenHelper) //Observable.Return(InitChildren()) Observable.Return(children.Connect()) ); - Task.Run(() => LoadChildren(container, directoryInfo, children, pointInTime)); + Task.Run(() => LoadChildren(container, directoryInfo, children, pointInTime, exceptions)); return container; @@ -241,25 +246,25 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo var items = GetItemsByContainer(directoryInfo, pointInTime); var result = new SourceCache(i => i.Path.Path); - if (items.Count == 0) return (IObservable>?)result.Connect().StartWithEmpty(); + if (items.Count == 0) return (IObservable>?) result.Connect().StartWithEmpty(); result.AddOrUpdate(items); - return (IObservable>?)result.Connect(); + return (IObservable>?) result.Connect(); } catch (Exception e) { - exceptions.OnNext(new List { e }); + exceptions.Add(e); } return null; } } - private void LoadChildren( - Container container, + private void LoadChildren(Container container, DirectoryInfo directoryInfo, SourceCache children, - PointInTime pointInTime) + PointInTime pointInTime, + SourceList exceptions) { var lockobj = new object(); var loadingIndicatorCancellation = new CancellationTokenSource(); @@ -275,18 +280,25 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo void LoadChildren() { - foreach (var directory in directoryInfo.EnumerateDirectories()) + try { - if (container.LoadingCancellationToken.IsCancellationRequested) break; - var absolutePath = DirectoryToAbsolutePath(directory, pointInTime); - children.AddOrUpdate(absolutePath); - } + foreach (var directory in directoryInfo.EnumerateDirectories()) + { + if (container.LoadingCancellationToken.IsCancellationRequested) break; + var absolutePath = DirectoryToAbsolutePath(directory, pointInTime); + children.AddOrUpdate(absolutePath); + } - foreach (var file in directoryInfo.EnumerateFiles()) + foreach (var file in directoryInfo.EnumerateFiles()) + { + if (container.LoadingCancellationToken.IsCancellationRequested) break; + var absolutePath = FileToAbsolutePath(file, pointInTime); + children.AddOrUpdate(absolutePath); + } + } + catch (Exception e) { - if (container.LoadingCancellationToken.IsCancellationRequested) break; - var absolutePath = FileToAbsolutePath(file, pointInTime); - children.AddOrUpdate(absolutePath); + exceptions.Add(e); } } @@ -297,7 +309,9 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo { await Task.Delay(500, token); } - catch { } + catch + { + } lock (lockobj) { @@ -345,7 +359,7 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo GetFileAttributes(fileInfo), this, pointInTime, - Observable.Return(Enumerable.Empty()), + new SourceList().Connect(), extensions.AsReadOnly() ); } @@ -357,8 +371,8 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo private FullName GetFullName(string nativePath) => FullName.CreateSafe((Name + Constants.SeparatorChar + - string.Join(Constants.SeparatorChar, - nativePath.TrimStart(Constants.SeparatorChar).Split(Path.DirectorySeparatorChar))) + string.Join(Constants.SeparatorChar, + nativePath.TrimStart(Constants.SeparatorChar).Split(Path.DirectorySeparatorChar))) .TrimEnd(Constants.SeparatorChar))!; public override NativePath GetNativePath(FullName fullName) @@ -385,7 +399,7 @@ public sealed partial class LocalContentProvider : ContentProviderBase, ILocalCo var size = maxLength ?? realFileSize switch { > int.MaxValue => int.MaxValue, - _ => (int)realFileSize + _ => (int) realFileSize }; var buffer = new byte[size]; await reader.ReadAsync(buffer.AsMemory(0, size), cancellationToken);