From 28640e5dd24280d1d733ad94a5a5455c8b4dd22c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20Kov=C3=A1cs?= Date: Tue, 29 Aug 2023 18:03:22 +0200 Subject: [PATCH] Multi instance, tab handling --- .../Models/TabsToOpenOnStart.cs | 7 + .../UserCommand/IdentifiableNewTabCommand.cs | 27 + .../UserCommand/SelectNextTabCommand.cs | 15 + .../UserCommand/SelectPreviousTabCommand.cs | 15 + .../Configuration/MainConfiguration.cs | 10 +- .../Persistence/TabPersistenceService.cs | 174 +++-- .../NavigationUserCommandHandlerService.cs | 123 +++- src/AppCommon/FileTime.App.Core/Startup.cs | 2 + ...faultIdentifiableCommandHandlerRegister.cs | 17 +- .../FileTime.GuiApp.App.csproj | 14 +- .../DummyInstanceMessageHandler.cs | 9 + .../InstanceManagement/IInstanceManager.cs | 10 + .../IInstanceMessageHandler.cs | 9 + .../InstanceManagement/InstanceManager.cs | 178 +++++ .../InstanceMessageHandler.cs | 41 ++ .../Messages/IInstanceMessage.cs | 9 + .../Messages/OpenContainers.cs | 20 + .../Services/SystemClipboardService.cs | 13 +- .../Settings/DefaultBrowserRegister.cs | 239 +++++++ .../Settings/IDefaultBrowserRegister.cs | 12 + .../Settings/ISettingsViewModel.cs | 10 + .../Settings/SettingsPane.cs | 7 + .../Settings/SettingsViewModel.cs | 66 ++ .../ViewModels/IMainWindowViewModel.cs | 1 + .../ViewModels/MainWindowViewModel.cs | 6 +- .../Views/MainWindow.axaml | 642 ++++++++++-------- .../Views/MainWindow.axaml.cs | 23 +- .../Views/SettingsWindow.axaml | 43 ++ .../Views/SettingsWindow.axaml.cs | 13 + .../Avalonia/FileTime.GuiApp/App.axaml.cs | 22 +- .../Avalonia/FileTime.GuiApp/Program.cs | 79 ++- .../Avalonia/FileTime.GuiApp/Startup.cs | 8 + .../TerminalUI.Tests/TerminalUI.Tests.csproj | 2 +- 33 files changed, 1430 insertions(+), 436 deletions(-) create mode 100644 src/AppCommon/FileTime.App.Core.Abstraction/Models/TabsToOpenOnStart.cs create mode 100644 src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/IdentifiableNewTabCommand.cs create mode 100644 src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/SelectNextTabCommand.cs create mode 100644 src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/SelectPreviousTabCommand.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/DummyInstanceMessageHandler.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/IInstanceManager.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/IInstanceMessageHandler.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/InstanceManager.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/InstanceMessageHandler.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/Messages/IInstanceMessage.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/Messages/OpenContainers.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/DefaultBrowserRegister.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/IDefaultBrowserRegister.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/ISettingsViewModel.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/SettingsPane.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/SettingsViewModel.cs create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/SettingsWindow.axaml create mode 100644 src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/SettingsWindow.axaml.cs diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/Models/TabsToOpenOnStart.cs b/src/AppCommon/FileTime.App.Core.Abstraction/Models/TabsToOpenOnStart.cs new file mode 100644 index 0000000..84a6867 --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/Models/TabsToOpenOnStart.cs @@ -0,0 +1,7 @@ +using FileTime.Core.Models; + +namespace FileTime.App.Core.Models; + +public record TabToOpen(int? TabNumber, NativePath Path); + +public record TabsToOpenOnStart(List TabsToOpen); \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/IdentifiableNewTabCommand.cs b/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/IdentifiableNewTabCommand.cs new file mode 100644 index 0000000..617889f --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/IdentifiableNewTabCommand.cs @@ -0,0 +1,27 @@ +using FileTime.Core.Models; + +namespace FileTime.App.Core.UserCommand; + +public class NewTabCommand : IUserCommand +{ + public NewTabCommand(FullName? path) + { + Path = path; + } + + public FullName? Path { get; } + public bool Open { get; init; } = true; +} +public class IdentifiableNewTabCommand : NewTabCommand, IIdentifiableUserCommand +{ + public const string CommandName = "new_tab"; + + public static IdentifiableNewTabCommand Instance { get; } = new(); + + private IdentifiableNewTabCommand():base(null) + { + + } + public string UserCommandID => CommandName; + public string Title => "New tab"; +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/SelectNextTabCommand.cs b/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/SelectNextTabCommand.cs new file mode 100644 index 0000000..7f6db9d --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/SelectNextTabCommand.cs @@ -0,0 +1,15 @@ +namespace FileTime.App.Core.UserCommand; + +public class SelectNextTabCommand : IIdentifiableUserCommand +{ + public const string CommandName = "next_tab"; + + public static SelectNextTabCommand Instance { get; } = new(); + + private SelectNextTabCommand() + { + + } + public string UserCommandID => CommandName; + public string Title => "Next tab"; +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/SelectPreviousTabCommand.cs b/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/SelectPreviousTabCommand.cs new file mode 100644 index 0000000..198d6b6 --- /dev/null +++ b/src/AppCommon/FileTime.App.Core.Abstraction/UserCommand/SelectPreviousTabCommand.cs @@ -0,0 +1,15 @@ +namespace FileTime.App.Core.UserCommand; + +public class SelectPreviousTabCommand : IIdentifiableUserCommand +{ + public const string CommandName = "previous_tab"; + + public static SelectPreviousTabCommand Instance { get; } = new(); + + private SelectPreviousTabCommand() + { + + } + public string UserCommandID => CommandName; + public string Title => "New tab"; +} \ No newline at end of file diff --git a/src/AppCommon/FileTime.App.Core/Configuration/MainConfiguration.cs b/src/AppCommon/FileTime.App.Core/Configuration/MainConfiguration.cs index 3a3323d..0359082 100644 --- a/src/AppCommon/FileTime.App.Core/Configuration/MainConfiguration.cs +++ b/src/AppCommon/FileTime.App.Core/Configuration/MainConfiguration.cs @@ -47,19 +47,15 @@ public class MainConfiguration private static List InitDefaultKeyBindings() => new List { - //new CommandBindingConfiguration(ConfigCommand.AutoRefresh, new KeyConfig(Keys.R, shift: true)), //new CommandBindingConfiguration(ConfigCommand.ChangeTimelineMode, new[] { Keys.T, Keys.M }), new(CloseTabCommand.CommandName, Keys.Q), - //new CommandBindingConfiguration(ConfigCommand.Compress, new[] { Keys.Y, Keys.C }), new(CopyBase64Command.CommandName, new[] {Keys.C, Keys.B}), new(CopyCommand.CommandName, new[] {Keys.Y, Keys.Y}), - //new CommandBindingConfiguration(ConfigCommand.CopyHash, new[] { Keys.C, Keys.H }), new(CopyNativePathCommand.CommandName, new[] {Keys.C, Keys.P}), new(CopyFilesToClipboardCommand.CommandName, new[] {Keys.Y, Keys.C}), new(CreateContainer.CommandName, Keys.F7), new(CreateContainer.CommandName, new[] {Keys.C, Keys.C}), new(CreateElementCommand.CommandName, new[] {Keys.C, Keys.E}), - //new CommandBindingConfiguration(ConfigCommand.Cut, new[] { Keys.D, Keys.D }), new(EditCommand.CommandName, new[] {Keys.F4}), new(EnterRapidTravelCommand.CommandName, new KeyConfig(Keys.Comma, shift: true)), new(EnterRapidTravelCommand.CommandName, new KeyConfig(Keys.Question, shift: true)), @@ -80,6 +76,7 @@ public class MainConfiguration new(MoveCursorDownCommand.CommandName, Keys.Down), new(MoveCursorUpPageCommand.CommandName, Keys.PageUp), new(MoveCursorDownPageCommand.CommandName, Keys.PageDown), + new(IdentifiableNewTabCommand.CommandName, new[] {new KeyConfig(Keys.T, ctrl: true)}), //new CommandBindingConfiguration(ConfigCommand.NextTimelineBlock, Keys.L ), //new CommandBindingConfiguration(ConfigCommand.NextTimelineCommand, Keys.J ), new(OpenSelectedCommand.CommandName, Keys.Right), @@ -91,7 +88,6 @@ public class MainConfiguration new(PasteCommand.PasteSkipCommandName, new[] {Keys.P, Keys.S}), new(PasteFilesFromClipboardCommand.PasteMergeCommandName, new[] {new KeyConfig(Keys.V, ctrl: true)}), new(PasteFilesFromClipboardCommand.PasteOverwriteCommandName, new[] {new KeyConfig(Keys.V, ctrl: true, shift: true)}), - //new CommandBindingConfiguration(ConfigCommand.PinFavorite, new[] { Keys.F, Keys.P }), //new CommandBindingConfiguration(ConfigCommand.PreviousTimelineBlock, Keys.H ), //new CommandBindingConfiguration(ConfigCommand.PreviousTimelineCommand, Keys.K ), new(RefreshCommand.CommandName, Keys.R), @@ -99,10 +95,10 @@ public class MainConfiguration new(RenameCommand.CommandName, new[] {Keys.C, Keys.W}), new(IdentifiableRunOrOpenCommand.CommandName, Keys.Enter), //new CommandBindingConfiguration(ConfigCommand.RunCommand, new KeyConfig(Keys.D4, shift: true)), - //new CommandBindingConfiguration(ConfigCommand.ScanContainerSize, new[] { Keys.C, Keys.S }), - //new CommandBindingConfiguration(ConfigCommand.ShowAllShortcut, Keys.F1), new(DeleteCommand.SoftDeleteCommandName, new[] {new KeyConfig(Keys.D), new KeyConfig(Keys.D, shift: true)}), new(IdentifiableSearchCommand.SearchByNameContainsCommandName, new[] {Keys.S, Keys.N}), + new(SelectNextTabCommand.CommandName, new[] {new KeyConfig(Keys.Tab, ctrl: true)}), + new(SelectPreviousTabCommand.CommandName, new[] {new KeyConfig(Keys.Tab, ctrl: true, shift: true)}), new(SwitchToTabCommand.SwitchToLastTabCommandName, new[] {new KeyConfig(Keys.Num9, alt: true)}), new(SwitchToTabCommand.SwitchToTab1CommandName, new[] {new KeyConfig(Keys.Num1, alt: true)}), new(SwitchToTabCommand.SwitchToTab2CommandName, new[] {new KeyConfig(Keys.Num2, alt: true)}), diff --git a/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs b/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs index e2fedd2..d66fe50 100644 --- a/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs +++ b/src/AppCommon/FileTime.App.Core/Services/Persistence/TabPersistenceService.cs @@ -36,6 +36,7 @@ public class TabPersistenceService : ITabPersistenceService private readonly IServiceProvider _serviceProvider; private readonly ILocalContentProvider _localContentProvider; private readonly TabPersistenceSettings _tabPersistenceSettings; + private readonly TabsToOpenOnStart _tabsToOpen; public TabPersistenceService( IApplicationSettings applicationSettings, @@ -44,6 +45,7 @@ public class TabPersistenceService : ITabPersistenceService IServiceProvider serviceProvider, ILocalContentProvider localContentProvider, TabPersistenceSettings tabPersistenceSettings, + TabsToOpenOnStart tabsToOpen, ILogger logger) { _appState = appState; @@ -53,6 +55,7 @@ public class TabPersistenceService : ITabPersistenceService _serviceProvider = serviceProvider; _localContentProvider = localContentProvider; _tabPersistenceSettings = tabPersistenceSettings; + _tabsToOpen = tabsToOpen; _jsonOptions = new JsonSerializerOptions { @@ -62,22 +65,63 @@ public class TabPersistenceService : ITabPersistenceService } public async Task InitAsync() - => await LoadStatesAsync(); + { + var containers = new List<(int? TabNumber, IContainer Container)>(); + + foreach (var (requestedTabNumber, nativePath) in _tabsToOpen.TabsToOpen) + { + if (await _timelessContentProvider.GetItemByNativePathAsync(nativePath) is not IContainer container) continue; + + containers.Add((requestedTabNumber, container)); + } + + var loadedTabViewModels = await LoadStatesAsync(containers.Count == 0); + + var tabViewModels = new List(); + foreach (var (requestedTabNumber, container) in containers) + { + var tabNumber = requestedTabNumber ?? 1; + + if (tabNumber < 1) tabNumber = 1; + + var tabNumbers = _appState.Tabs.Select(t => t.TabNumber).ToHashSet(); + + while (tabNumbers.Contains(tabNumber)) + { + tabNumber++; + } + + var tab = await _serviceProvider.GetAsyncInitableResolver(container) + .GetRequiredServiceAsync(); + var tabViewModel = _serviceProvider.GetInitableResolver(tab, tabNumber).GetRequiredService(); + + _appState.AddTab(tabViewModel); + tabViewModels.Add(tabViewModel); + } + + tabViewModels.Reverse(); + await _appState.SetSelectedTabAsync(tabViewModels.Concat(loadedTabViewModels).First()); + } public Task ExitAsync(CancellationToken token = default) { - if(!_tabPersistenceSettings.SaveState) return Task.CompletedTask; + if (!_tabPersistenceSettings.SaveState) return Task.CompletedTask; SaveStates(token); return Task.CompletedTask; } - private async Task LoadStatesAsync(CancellationToken token = default) + private async Task> LoadStatesAsync(bool createEmptyIfNecessary, CancellationToken token = default) { if (!File.Exists(_settingsPath) || !_tabPersistenceSettings.LoadState) { - await CreateEmptyTab(); - return; + if (createEmptyIfNecessary) + { + var tabViewModel = await CreateEmptyTab(); + return new[] {tabViewModel}; + } + + return Enumerable.Empty(); } try @@ -86,7 +130,8 @@ public class TabPersistenceService : ITabPersistenceService var state = await JsonSerializer.DeserializeAsync(stateReader, cancellationToken: token); if (state != null) { - if (await RestoreTabs(state.TabStates)) return; + var (success, tabViewModels) = await RestoreTabs(state.TabStates); + if (success) return tabViewModels; } } catch (Exception e) @@ -94,9 +139,15 @@ public class TabPersistenceService : ITabPersistenceService _logger.LogError(e, "Unknown exception while restoring app state"); } - await CreateEmptyTab(); + if (createEmptyIfNecessary) + { + var tabViewModel = await CreateEmptyTab(); + return new[] {tabViewModel}; + } - async Task CreateEmptyTab() + return Enumerable.Empty(); + + async Task CreateEmptyTab() { IContainer? currentDirectory = null; try @@ -116,81 +167,75 @@ public class TabPersistenceService : ITabPersistenceService var tabViewModel = _serviceProvider.GetInitableResolver(tab, 1).GetRequiredService(); _appState.AddTab(tabViewModel); + + return tabViewModel; } } - private async Task RestoreTabs(TabStates? tabStates) + private async Task<(bool Success, IEnumerable)> RestoreTabs(TabStates? tabStates) { if (tabStates == null || tabStates.Tabs == null) { - return false; + return (false, Enumerable.Empty()); } - try + foreach (var tab in tabStates.Tabs) { - foreach (var tab in tabStates.Tabs) + try { - try + if (tab.Path == null) continue; + if (_contentProvidersNotToRestore.Any(p => tab.Path.StartsWith(p))) continue; + + IContainer? container = null; + var path = FullName.CreateSafe(tab.Path); + while (true) { - if (tab.Path == null) continue; - if (_contentProvidersNotToRestore.Any(p => tab.Path.StartsWith(p))) continue; - - IContainer? container = null; - var path = FullName.CreateSafe(tab.Path); - while (true) + try { - try - { - var pathItem = - await _timelessContentProvider.GetItemByFullNameAsync(path, PointInTime.Present); + var pathItem = + await _timelessContentProvider.GetItemByFullNameAsync(path, PointInTime.Present); - container = pathItem switch - { - IContainer c => c, - IElement e => - e.Parent is null - ? null - : await e.Parent.ResolveAsync() as IContainer, - _ => null - }; - break; - } - catch + container = pathItem switch { - path = path?.GetParent(); - if (path == null) - { - throw new Exception($"Could not find an initializable path along {tab.Path}"); - } + IContainer c => c, + IElement e => + e.Parent is null + ? null + : await 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; - - if (_contentProvidersNotToRestore.Contains(container.Provider.Name)) continue; - - var tabToLoad = await _serviceProvider.GetAsyncInitableResolver(container) - .GetRequiredServiceAsync(); - var tabViewModel = _serviceProvider.GetInitableResolver(tabToLoad, tab.Number) - .GetRequiredService(); - - _appState.AddTab(tabViewModel); - } - catch (Exception e) - { - _logger.LogError(e, "Unknown exception while restoring tab. {TabState}", - JsonSerializer.Serialize(tab, _jsonOptions)); } + + if (container == null) continue; + + if (_contentProvidersNotToRestore.Contains(container.Provider.Name)) continue; + + var tabToLoad = await _serviceProvider.GetAsyncInitableResolver(container) + .GetRequiredServiceAsync(); + var tabViewModel = _serviceProvider.GetInitableResolver(tabToLoad, tab.Number) + .GetRequiredService(); + + _appState.AddTab(tabViewModel); + } + catch (Exception e) + { + _logger.LogError(e, "Unknown exception while restoring tab. {TabState}", + JsonSerializer.Serialize(tab, _jsonOptions)); } } - catch (Exception e) - { - _logger.LogError(e, "Unknown exception while restoring tabs"); - return false; - } - if (_appState.Tabs.Count == 0) return false; + if (_appState.Tabs.Count == 0) return (false, Enumerable.Empty()); var optimalTabs = _appState .Tabs @@ -200,10 +245,7 @@ public class TabPersistenceService : ITabPersistenceService .Tabs .SkipWhile(t => t.TabNumber <= tabStates.ActiveTabNumber); - var tabToActivate = optimalTabs.Concat(suboptimalTabs).FirstOrDefault(); - if (tabToActivate is not null) await _appState.SetSelectedTabAsync(tabToActivate); - - return true; + return (true, optimalTabs.Concat(suboptimalTabs)); } public void SaveStates(CancellationToken token = default) diff --git a/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/NavigationUserCommandHandlerService.cs b/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/NavigationUserCommandHandlerService.cs index de7fdc2..5e366c8 100644 --- a/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/NavigationUserCommandHandlerService.cs +++ b/src/AppCommon/FileTime.App.Core/Services/UserCommandHandler/NavigationUserCommandHandlerService.cs @@ -86,11 +86,14 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase new TypeUserCommandHandler(MoveCursorToLast), new TypeUserCommandHandler(MoveCursorUp), new TypeUserCommandHandler(MoveCursorUpPage), + new TypeUserCommandHandler(NewTabAsync), new TypeUserCommandHandler(OpenCommandPalette), new TypeUserCommandHandler(OpenContainer), new TypeUserCommandHandler(OpenSelected), new TypeUserCommandHandler(RunOrOpen), new TypeUserCommandHandler(Refresh), + new TypeUserCommandHandler(SelectNextTab), + new TypeUserCommandHandler(SelectPreviousTab), new TypeUserCommandHandler(SwitchToTab), }); } @@ -346,38 +349,19 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase private async Task SwitchToTab(SwitchToTabCommand command) { - var number = command.TabNumber; - var tabViewModel = _appState.Tabs.FirstOrDefault(t => t.TabNumber == number); + var tabNumber = command.TabNumber; + var tabViewModel = _appState.Tabs.FirstOrDefault(t => t.TabNumber == tabNumber); - if (number == -1) + if (tabNumber == -1) { var greatestNumber = _appState.Tabs.Max(t => t.TabNumber); tabViewModel = _appState.Tabs.FirstOrDefault(t => t.TabNumber == greatestNumber); } - else if (tabViewModel == null) + + if (tabViewModel == null) { - IContainer? newLocation = null; - - try - { - newLocation = _currentLocation?.Value?.FullName is { } fullName - ? (IContainer) await _timelessContentProvider.GetItemByFullNameAsync(fullName, PointInTime.Present) - : _localContentProvider; - } - catch (Exception ex) - { - var fullName = _currentLocation?.Value?.FullName?.Path ?? "unknown"; - _logger.LogError(ex, "Could not resolve container while switching to tab {TabNumber} to path {FullName}", number, fullName); - } - - newLocation ??= _localContentProvider; - - var tab = await _serviceProvider.GetAsyncInitableResolver(newLocation) - .GetRequiredServiceAsync(); - var newTabViewModel = _serviceProvider.GetInitableResolver(tab, number).GetRequiredService(); - - _appState.AddTab(newTabViewModel); - tabViewModel = newTabViewModel; + var newLocation = await GetLocationForNewTabAsync(tabNumber); + tabViewModel = await CreateTabAsync(newLocation, tabNumber); } if (_viewMode == ViewMode.RapidTravel) @@ -385,9 +369,96 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase await _userCommandHandlerService.HandleCommandAsync(ExitRapidTravelCommand.Instance); } + await _appState.SetSelectedTabAsync(tabViewModel); + } + + private async Task NewTabAsync(NewTabCommand command) + { + var numbers = _appState.Tabs.Select(t => t.TabNumber).ToHashSet(); + + var tabNumber = 1; + while (numbers.Contains(tabNumber)) + { + tabNumber++; + } + + IContainer? newLocation = null; + + if (command.Path is { } path) + { + newLocation = await _timelessContentProvider.GetItemByFullNameAsync(path, PointInTime.Present) as IContainer; + } + + newLocation ??= await GetLocationForNewTabAsync(tabNumber); + var tabViewModel = await CreateTabAsync(newLocation, tabNumber); + + if (_viewMode == ViewMode.RapidTravel) + { + await _userCommandHandlerService.HandleCommandAsync(ExitRapidTravelCommand.Instance); + } + + if (command.Open) + { + await _appState.SetSelectedTabAsync(tabViewModel); + } + } + + private async Task SelectNextTab() + { + var currentTabNumber = _appState.SelectedTab.Value?.TabNumber; + + var nextTabNumbers = _appState.Tabs.Select(t => t.TabNumber).Order().SkipWhile(n => n <= currentTabNumber).ToArray(); + + if (nextTabNumbers.Length == 0) return; + + var nextTabNumber = nextTabNumbers[0]; + var tabViewModel = _appState.Tabs.FirstOrDefault(t => t.TabNumber == nextTabNumber); await _appState.SetSelectedTabAsync(tabViewModel!); } + private async Task SelectPreviousTab() + { + var currentTabNumber = _appState.SelectedTab.Value?.TabNumber; + + var nextTabNumbers = _appState.Tabs.Select(t => t.TabNumber).Order().TakeWhile(n => n < currentTabNumber).ToArray(); + + if (nextTabNumbers.Length == 0) return; + + var nextTabNumber = nextTabNumbers[^1]; + var tabViewModel = _appState.Tabs.FirstOrDefault(t => t.TabNumber == nextTabNumber); + await _appState.SetSelectedTabAsync(tabViewModel!); + } + + private async Task GetLocationForNewTabAsync(int tabNumber) + { + try + { + var newLocation = _currentLocation?.Value?.FullName is { } fullName + ? (IContainer) await _timelessContentProvider.GetItemByFullNameAsync(fullName, PointInTime.Present) + : _localContentProvider; + + return newLocation; + } + catch (Exception ex) + { + var fullName = _currentLocation?.Value?.FullName?.Path ?? "unknown"; + _logger.LogError(ex, "Could not resolve container while switching to tab {TabNumber} to path {FullName}", tabNumber, fullName); + } + + return _localContentProvider; + } + + private async Task CreateTabAsync(IContainer newLocation, int tabNumber) + { + var tab = await _serviceProvider.GetAsyncInitableResolver(newLocation) + .GetRequiredServiceAsync(); + var newTabViewModel = _serviceProvider.GetInitableResolver(tab, tabNumber).GetRequiredService(); + + _appState.AddTab(newTabViewModel); + + return newTabViewModel; + } + private Task CloseTab() { if ((!_applicationConfiguration.AllowCloseLastTab && _appState.Tabs.Count < 2) || _selectedTab == null) return Task.CompletedTask; diff --git a/src/AppCommon/FileTime.App.Core/Startup.cs b/src/AppCommon/FileTime.App.Core/Startup.cs index 5613fe8..3c06586 100644 --- a/src/AppCommon/FileTime.App.Core/Startup.cs +++ b/src/AppCommon/FileTime.App.Core/Startup.cs @@ -1,4 +1,5 @@ using FileTime.App.Core.Configuration; +using FileTime.App.Core.Models; using FileTime.App.Core.Services; using FileTime.App.Core.Services.UserCommandHandler; using FileTime.App.Core.StartupServices; @@ -36,6 +37,7 @@ public static class Startup serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(new TabsToOpenOnStart(new List())); return serviceCollection .AddCommandHandlers() diff --git a/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs b/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs index cbd5f05..07f5e0a 100644 --- a/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs +++ b/src/AppCommon/FileTime.App.Core/StartupServices/DefaultIdentifiableCommandHandlerRegister.cs @@ -13,8 +13,8 @@ public class DefaultIdentifiableCommandHandlerRegister : IStartupHandler AddUserCommand(AddRemoteContentProviderCommand.Instance); AddUserCommand(CloseTabCommand.Instance); - AddUserCommand(CopyCommand.Instance); AddUserCommand(CopyBase64Command.Instance); + AddUserCommand(CopyCommand.Instance); AddUserCommand(CopyFilesToClipboardCommand.Instance); AddUserCommand(CopyNativePathCommand.Instance); AddUserCommand(CreateContainer.Instance); @@ -32,6 +32,9 @@ public class DefaultIdentifiableCommandHandlerRegister : IStartupHandler AddUserCommand(GoToProviderCommand.Instance); AddUserCommand(GoToRootCommand.Instance); AddUserCommand(GoUpCommand.Instance); + AddUserCommand(IdentifiableRunOrOpenCommand.Instance); + AddUserCommand(IdentifiableSearchCommand.SearchByNameContains); + AddUserCommand(IdentifiableSearchCommand.SearchByRegex); AddUserCommand(MarkCommand.Instance); AddUserCommand(MoveCursorDownCommand.Instance); AddUserCommand(MoveCursorDownPageCommand.Instance); @@ -39,6 +42,7 @@ public class DefaultIdentifiableCommandHandlerRegister : IStartupHandler AddUserCommand(MoveCursorToLastCommand.Instance); AddUserCommand(MoveCursorUpCommand.Instance); AddUserCommand(MoveCursorUpPageCommand.Instance); + AddUserCommand(IdentifiableNewTabCommand.Instance); AddUserCommand(OpenCommandPaletteCommand.Instance); AddUserCommand(OpenInDefaultFileExplorerCommand.Instance); AddUserCommand(OpenSelectedCommand.Instance); @@ -51,19 +55,18 @@ public class DefaultIdentifiableCommandHandlerRegister : IStartupHandler AddUserCommand(PauseCommandSchedulerCommand.Instance); AddUserCommand(RefreshCommand.Instance); AddUserCommand(RenameCommand.Instance); - AddUserCommand(IdentifiableRunOrOpenCommand.Instance); AddUserCommand(ScanSizeCommand.Instance); - AddUserCommand(StartCommandSchedulerCommand.Instance); - AddUserCommand(SortItemsCommand.OrderByNameCommand); - AddUserCommand(SortItemsCommand.OrderByNameDescCommand); + AddUserCommand(SelectNextTabCommand.Instance); + AddUserCommand(SelectPreviousTabCommand.Instance); AddUserCommand(SortItemsCommand.OrderByCreatedAtCommand); AddUserCommand(SortItemsCommand.OrderByCreatedAtDescCommand); AddUserCommand(SortItemsCommand.OrderByLastModifiedCommand); AddUserCommand(SortItemsCommand.OrderByLastModifiedDescCommand); + AddUserCommand(SortItemsCommand.OrderByNameCommand); + AddUserCommand(SortItemsCommand.OrderByNameDescCommand); AddUserCommand(SortItemsCommand.OrderBySizeCommand); AddUserCommand(SortItemsCommand.OrderBySizeDescCommand); - AddUserCommand(IdentifiableSearchCommand.SearchByNameContains); - AddUserCommand(IdentifiableSearchCommand.SearchByRegex); + AddUserCommand(StartCommandSchedulerCommand.Instance); AddUserCommand(SwitchToTabCommand.SwitchToLastTab); AddUserCommand(SwitchToTabCommand.SwitchToTab1); AddUserCommand(SwitchToTabCommand.SwitchToTab2); 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 7b77a07..64b65ef 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/FileTime.GuiApp.App.csproj +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/FileTime.GuiApp.App.csproj @@ -25,12 +25,21 @@ - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -48,4 +57,7 @@ + + + diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/DummyInstanceMessageHandler.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/DummyInstanceMessageHandler.cs new file mode 100644 index 0000000..52b215b --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/DummyInstanceMessageHandler.cs @@ -0,0 +1,9 @@ +using FileTime.GuiApp.App.InstanceManagement.Messages; + +namespace FileTime.GuiApp.App.InstanceManagement; + +public class DummyInstanceMessageHandler : IInstanceMessageHandler +{ + public Task HandleMessageAsync(IInstanceMessage message) => throw new NotImplementedException(); + public event Action? ShowWindow; +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/IInstanceManager.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/IInstanceManager.cs new file mode 100644 index 0000000..b618714 --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/IInstanceManager.cs @@ -0,0 +1,10 @@ +using FileTime.App.Core.Services; +using FileTime.GuiApp.App.InstanceManagement.Messages; + +namespace FileTime.GuiApp.App.InstanceManagement; + +public interface IInstanceManager : IStartupHandler, IExitHandler +{ + Task SendMessageAsync(T message, CancellationToken token = default) where T : class, IInstanceMessage; + Task TryConnectAsync(CancellationToken token = default); +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/IInstanceMessageHandler.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/IInstanceMessageHandler.cs new file mode 100644 index 0000000..60f88ad --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/IInstanceMessageHandler.cs @@ -0,0 +1,9 @@ +using FileTime.GuiApp.App.InstanceManagement.Messages; + +namespace FileTime.GuiApp.App.InstanceManagement; + +public interface IInstanceMessageHandler +{ + Task HandleMessageAsync(IInstanceMessage message); + event Action? ShowWindow; +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/InstanceManager.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/InstanceManager.cs new file mode 100644 index 0000000..d4b3db0 --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/InstanceManager.cs @@ -0,0 +1,178 @@ +using System.IO.Pipes; +using Avalonia.Controls; +using FileTime.GuiApp.App.InstanceManagement.Messages; +using MessagePack; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FileTime.GuiApp.App.InstanceManagement; + +public sealed class InstanceManager : IInstanceManager +{ + private const string PipeName = "FileTime.GuiApp"; + + private readonly IInstanceMessageHandler _instanceMessageHandler; + private readonly ILogger _logger; + private readonly CancellationTokenSource _serverCancellationTokenSource = new(); + private Thread? _serverThread; + private NamedPipeClientStream? _pipeClientStream; + private readonly List _pipeServerStreams = new(); + + [ActivatorUtilitiesConstructor] + public InstanceManager( + IInstanceMessageHandler instanceMessageHandler, + ILogger logger) + { + _instanceMessageHandler = instanceMessageHandler; + _logger = logger; + } + + public InstanceManager( + IInstanceMessageHandler instanceMessageHandler, + ILogger logger) + { + _instanceMessageHandler = instanceMessageHandler; + _logger = logger; + } + + public async Task TryConnectAsync(CancellationToken token = default) + { + if (_pipeClientStream is not null) return true; + + _pipeClientStream = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut); + try + { + await _pipeClientStream.ConnectAsync(200, token); + + } + catch + { + return false; + } + + return true; + } + + public Task ExitAsync(CancellationToken token = default) + { + _serverCancellationTokenSource.Cancel(); + + var pipeServerStreams = _pipeServerStreams.ToArray(); + _pipeServerStreams.Clear(); + + foreach (var pipeServerStream in pipeServerStreams) + { + try + { + pipeServerStream.Close(); + pipeServerStream.Dispose(); + } + catch + { + // ignored + } + } + + + return Task.CompletedTask; + } + + public Task InitAsync() + { + _serverThread = new Thread(StartServer); + _serverThread.Start(); + + return Task.CompletedTask; + } + + private async void StartServer() + { + try + { + if (await TryConnectAsync()) + { + //An instance already exists, this one won't listen for connections + return; + } + + while (true) + { + var pipeServer = new NamedPipeServerStream(PipeName, PipeDirection.InOut, 200); + _pipeServerStreams.Add(pipeServer); + try + { + await pipeServer.WaitForConnectionAsync(_serverCancellationTokenSource.Token); + ThreadPool.QueueUserWorkItem(HandleConnection, pipeServer); + } + catch (OperationCanceledException) + { + break; + } + } + } + catch (Exception e) + { + _logger.LogError(e, "Error in server thread"); + } + } + + private async void HandleConnection(object? state) + { + if (state is not NamedPipeServerStream pipeServer) + throw new ArgumentException(nameof(state) + "is not" + nameof(NamedPipeServerStream)); + + while (true) + { + IInstanceMessage message; + try + { + message = await ReadMessageAsync(pipeServer, _serverCancellationTokenSource.Token); + } + catch (TaskCanceledException) + { + break; + } + catch (MessagePackSerializationException) + { + break; + } + catch (Exception e) + { + _logger.LogError(e, "Error while reading message"); + break; + } + + try + { + await _instanceMessageHandler.HandleMessageAsync(message); + } + catch (Exception e) + { + _logger.LogError(e, "Error while handling message"); + break; + } + } + + _pipeServerStreams.Remove(pipeServer); + pipeServer.Close(); + await pipeServer.DisposeAsync(); + } + + public async Task SendMessageAsync(T message, CancellationToken token = default) where T : class, IInstanceMessage + { + if (!await TryConnectAsync(token)) + { + _logger.LogWarning("Could not connect to server, can send message {Message}", message); + return; + } + + await WriteMessageAsync(_pipeClientStream!, message, token); + await _pipeClientStream!.FlushAsync(token); + } + + private static async Task ReadMessageAsync(Stream stream, CancellationToken token = default) + => await MessagePackSerializer.DeserializeAsync(stream, cancellationToken: token); + + private static async Task WriteMessageAsync(Stream stream, IInstanceMessage message, CancellationToken token = default) + => await MessagePackSerializer.SerializeAsync(stream, message, cancellationToken: token); +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/InstanceMessageHandler.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/InstanceMessageHandler.cs new file mode 100644 index 0000000..3f3396c --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/InstanceMessageHandler.cs @@ -0,0 +1,41 @@ +using FileTime.App.Core.Services; +using FileTime.App.Core.UserCommand; +using FileTime.Core.Models; +using FileTime.Core.Timeline; +using FileTime.GuiApp.App.InstanceManagement.Messages; + +namespace FileTime.GuiApp.App.InstanceManagement; + +public class InstanceMessageHandler : IInstanceMessageHandler +{ + private readonly IUserCommandHandlerService _userCommandHandlerService; + private readonly ITimelessContentProvider _timelessContentProvider; + + public event Action? ShowWindow; + + public InstanceMessageHandler( + IUserCommandHandlerService userCommandHandlerService, + ITimelessContentProvider timelessContentProvider + ) + { + _userCommandHandlerService = userCommandHandlerService; + _timelessContentProvider = timelessContentProvider; + } + + public async Task HandleMessageAsync(IInstanceMessage message) + { + if (message is OpenContainers openContainers) + { + foreach (var container in openContainers.Containers) + { + var fullName = await _timelessContentProvider.GetFullNameByNativePathAsync(new NativePath(container)); + + if (fullName is null) continue; + + await _userCommandHandlerService.HandleCommandAsync(new NewTabCommand(fullName)); + } + + ShowWindow?.Invoke(); + } + } +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/Messages/IInstanceMessage.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/Messages/IInstanceMessage.cs new file mode 100644 index 0000000..a2b6636 --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/Messages/IInstanceMessage.cs @@ -0,0 +1,9 @@ + + +namespace FileTime.GuiApp.App.InstanceManagement.Messages; + +[MessagePack.Union(0, typeof(OpenContainers))] +public interface IInstanceMessage +{ + +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/Messages/OpenContainers.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/Messages/OpenContainers.cs new file mode 100644 index 0000000..386c112 --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/InstanceManagement/Messages/OpenContainers.cs @@ -0,0 +1,20 @@ +using MessagePack; + +namespace FileTime.GuiApp.App.InstanceManagement.Messages; + +[MessagePackObject] +public class OpenContainers : IInstanceMessage +{ + public OpenContainers() + { + + } + + public OpenContainers(IEnumerable containers) + { + Containers.AddRange(containers); + } + + [Key(0)] + public List Containers { get; set; } = new(); +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Services/SystemClipboardService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Services/SystemClipboardService.cs index ecb9cfc..0485d7d 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Services/SystemClipboardService.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Services/SystemClipboardService.cs @@ -71,10 +71,15 @@ public class SystemClipboardService : ISystemClipboardService return; } - var fileNativePaths = files - .Select(i => _timelessContentProvider.GetNativePathByFullNameAsync(i)) - .Where(i => i != null) - .OfType(); + var fileNativePaths = new List(); + foreach (var file in files) + { + var fileNativePath = await _timelessContentProvider.GetNativePathByFullNameAsync(file); + if (fileNativePath is not null) + { + fileNativePaths.Add(fileNativePath); + } + } var targetFiles = new List(); foreach (var fileNativePath in fileNativePaths) diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/DefaultBrowserRegister.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/DefaultBrowserRegister.cs new file mode 100644 index 0000000..2d7be4b --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/DefaultBrowserRegister.cs @@ -0,0 +1,239 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace FileTime.GuiApp.App.Settings; + +public class DefaultBrowserRegister : IDefaultBrowserRegister +{ + private const string WinEKeyPathSub = @"shell\opennewwindow\command"; + private const string WinEKeyPathRoot = @"SOFTWARE\Classes\CLSID\{52205fd8-5dfb-447d-801a-d0b52f2e83e1}"; + private const string WinEKeyPath = $@"{WinEKeyPathRoot}\{WinEKeyPathSub}"; + private const string FullWinEKeyPath = $@"HKEY_CURRENT_USER\{WinEKeyPath}"; + private const string FullWinEKeyPathRoot = $@"HKEY_CURRENT_USER\{WinEKeyPathRoot}"; + + private const string DefaultBrowserKeyPath = @"SOFTWARE\Classes\Folder\shell\open\command"; + private const string FullDefaultBrowserKeyPath = $@"HKEY_CURRENT_USER\{DefaultBrowserKeyPath}"; + + private readonly ILogger _logger; + + public DefaultBrowserRegister(ILogger logger) + { + _logger = logger; + } + + public async void RegisterAsDefaultEditor() + { + string? tempFile = null; + try + { + tempFile = Path.GetTempFileName() + ".reg"; + var hexPath = GetFileTimeHexPath("\"%1\""); + await using (var streamWriter = new StreamWriter(tempFile)) + { + await streamWriter.WriteLineAsync("Windows Registry Editor Version 5.00"); + await streamWriter.WriteLineAsync(); + var s = $$""" + [{{FullDefaultBrowserKeyPath}}] + + @={{hexPath}} + "DelegateExecute"="" + + [HKEY_CURRENT_USER\SOFTWARE\Classes\Folder\shell\explore\command] + + @={{hexPath}} + "DelegateExecute"="" + """; + await streamWriter.WriteLineAsync(s); + + await streamWriter.FlushAsync(); + } + + await StartRegeditProcessAsync(tempFile); + } + catch (Exception e) + { + _logger.LogError(e, "Error while registering Win+E shortcut"); + } + finally + { + if (tempFile is not null) + { + File.Delete(tempFile); + } + } + } + + public async void UnregisterAsDefaultEditor() + { + string? tempFile = null; + try + { + tempFile = Path.GetTempFileName() + ".reg"; + await using (var streamWriter = new StreamWriter(tempFile)) + { + await streamWriter.WriteLineAsync("Windows Registry Editor Version 5.00"); + await streamWriter.WriteLineAsync(); + var s = $$""" + [HKEY_CURRENT_USER\SOFTWARE\Classes\Folder\shell\open] + "MultiSelectModel"="Document" + + [{{FullDefaultBrowserKeyPath}}] + @=hex(2):25,00,53,00,79,00,73,00,74,00,65,00,6d,00,52,00,6f,00,6f,00,74,00,25,\ + 00,5c,00,45,00,78,00,70,00,6c,00,6f,00,72,00,65,00,72,00,2e,00,65,00,78,00,\ + 65,00,00,00 + "DelegateExecute"="{11dbb47c-a525-400b-9e80-a54615a090c0}" + + [HKEY_CURRENT_USER\SOFTWARE\Classes\Folder\shell\explore] + "LaunchExplorerFlags"=dword:00000018 + "MultiSelectModel"="Document" + "ProgrammaticAccessOnly"="" + + [HKEY_CURRENT_USER\SOFTWARE\Classes\Folder\shell\explore\command] + @=- + "DelegateExecute"="{11dbb47c-a525-400b-9e80-a54615a090c0}" + """; + await streamWriter.WriteLineAsync(s); + + await streamWriter.FlushAsync(); + } + + await StartRegeditProcessAsync(tempFile); + } + catch (Exception e) + { + _logger.LogError(e, "Error while registering Win+E shortcut"); + } + finally + { + if (tempFile is not null) + { + File.Delete(tempFile); + } + } + } + + public async void RegisterWinEShortcut() + { + string? tempFile = null; + try + { + tempFile = Path.GetTempFileName() + ".reg"; + var hexPath = GetFileTimeHexPath(); + await using (var streamWriter = new StreamWriter(tempFile)) + { + await streamWriter.WriteLineAsync("Windows Registry Editor Version 5.00"); + await streamWriter.WriteLineAsync(); + var s = $$""" + [{{FullWinEKeyPath}}] + + @={{hexPath}} + "DelegateExecute"="" + """; + await streamWriter.WriteLineAsync(s); + + await streamWriter.FlushAsync(); + } + + await StartRegeditProcessAsync(tempFile); + } + catch (Exception e) + { + _logger.LogError(e, "Error while registering Win+E shortcut"); + } + finally + { + if (tempFile is not null) + { + File.Delete(tempFile); + } + } + } + + public async void UnregisterWinEShortcut() + { + string? tempFile = null; + try + { + tempFile = Path.GetTempFileName() + ".reg"; + await using (var streamWriter = new StreamWriter(tempFile)) + { + await streamWriter.WriteLineAsync("Windows Registry Editor Version 5.00"); + await streamWriter.WriteLineAsync(); + var s = $"[-{FullWinEKeyPathRoot}]"; + await streamWriter.WriteLineAsync(s); + + await streamWriter.FlushAsync(); + } + + await StartRegeditProcessAsync(tempFile); + } + catch (Exception e) + { + _logger.LogError(e, "Error while unregistering Win+E shortcut"); + } + finally + { + if (tempFile is not null) + { + File.Delete(tempFile); + } + } + } + + public bool IsWinEShortcut() => IsKeyContainsPath(WinEKeyPath); + + public bool IsDefaultFileBrowser() => IsKeyContainsPath(DefaultBrowserKeyPath); + + private bool IsKeyContainsPath(string key) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return false; + + using var subKey = Registry.CurrentUser.OpenSubKey(key); + var command = (string?) subKey?.GetValue(string.Empty); + + return !string.IsNullOrEmpty(command) && command.Contains(GetFileTimeExecutablePath()); + } + + private string GetFileTimeHexPath(string? parameter = null) + { + var fileTimePath = GetFileTimeExecutablePath(); + var hexPath = GetRegistryHexString("\"" + fileTimePath + "\"" + (parameter is null ? "" : " " + parameter)); + return hexPath; + } + + private async Task StartRegeditProcessAsync(string regFilePath) + { + try + { + using var regProcess = Process.Start( + new ProcessStartInfo( + "regedit.exe", + @$"/s ""{regFilePath}""") + { + UseShellExecute = true, + Verb = "runas" + }); + + if (regProcess is not null) + { + await regProcess.WaitForExitAsync(); + } + } + catch + { + // Canceled UAC + } + } + + private string GetFileTimeExecutablePath() + => Process.GetCurrentProcess().MainModule!.FileName; + + private string GetRegistryHexString(string s) + { + var bytes = Encoding.Unicode.GetBytes(s); + return "hex(2):" + BitConverter.ToString(bytes).Replace("-", ","); + } +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/IDefaultBrowserRegister.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/IDefaultBrowserRegister.cs new file mode 100644 index 0000000..d7cd63b --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/IDefaultBrowserRegister.cs @@ -0,0 +1,12 @@ +namespace FileTime.GuiApp.App.Settings; + +public interface IDefaultBrowserRegister +{ + void RegisterAsDefaultEditor(); + void UnregisterAsDefaultEditor(); + + void RegisterWinEShortcut(); + void UnregisterWinEShortcut(); + bool IsWinEShortcut(); + bool IsDefaultFileBrowser(); +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/ISettingsViewModel.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/ISettingsViewModel.cs new file mode 100644 index 0000000..fced5f7 --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/ISettingsViewModel.cs @@ -0,0 +1,10 @@ +namespace FileTime.GuiApp.App.Settings; + +public interface ISettingsViewModel +{ + SettingsPane SelectedPane { get; set; } + bool SetAsDefaultIsChecked { get; set; } + List PaneItems { get; } + SettingsPaneItem SelectedPaneItem { get; set; } + bool SetAsWinEHandlerIsChecked { get; set; } +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/SettingsPane.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/SettingsPane.cs new file mode 100644 index 0000000..5f1973f --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/SettingsPane.cs @@ -0,0 +1,7 @@ +namespace FileTime.GuiApp.App.Settings; + +public enum SettingsPane +{ + Home, + Advanced +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/SettingsViewModel.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/SettingsViewModel.cs new file mode 100644 index 0000000..8933a26 --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Settings/SettingsViewModel.cs @@ -0,0 +1,66 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using PropertyChanged.SourceGenerator; + +namespace FileTime.GuiApp.App.Settings; + +public record SettingsPaneItem(string Header, SettingsPane Pane); + +public partial class SettingsViewModel : ISettingsViewModel +{ + private readonly IDefaultBrowserRegister _defaultBrowserRegister; + + [Notify] private SettingsPane _selectedPane; + [Notify] private bool _setAsDefaultIsChecked; + [Notify] private bool _setAsWinEHandlerIsChecked; + + public bool ShowWindowsSpecificSettings => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public List PaneItems { get; } = new() + { + new("Home", SettingsPane.Home), + new("Advanced", SettingsPane.Advanced) + }; + + public SettingsPaneItem SelectedPaneItem + { + get => PaneItems.First(x => x.Pane == SelectedPane); + set => SelectedPane = value.Pane; + } + + public SettingsViewModel(IDefaultBrowserRegister defaultBrowserRegister) + { + _defaultBrowserRegister = defaultBrowserRegister; + + _setAsWinEHandlerIsChecked = defaultBrowserRegister.IsWinEShortcut(); + _setAsDefaultIsChecked = defaultBrowserRegister.IsDefaultFileBrowser(); + + PropertyChanged += SettingsViewModel_PropertyChanged; + } + + private void SettingsViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SetAsDefaultIsChecked)) + { + if (SetAsDefaultIsChecked) + { + _defaultBrowserRegister.RegisterAsDefaultEditor(); + } + else + { + _defaultBrowserRegister.UnregisterAsDefaultEditor(); + } + } + else if (e.PropertyName == nameof(SetAsWinEHandlerIsChecked)) + { + if (SetAsWinEHandlerIsChecked) + { + _defaultBrowserRegister.RegisterWinEShortcut(); + } + else + { + _defaultBrowserRegister.UnregisterWinEShortcut(); + } + } + } +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/IMainWindowViewModel.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/IMainWindowViewModel.cs index 3700e81..d20e355 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/IMainWindowViewModel.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/IMainWindowViewModel.cs @@ -20,5 +20,6 @@ public interface IMainWindowViewModel : IMainWindowViewModelBase IClipboardService ClipboardService { get; } ITimelineViewModel TimelineViewModel { get; } IPossibleCommandsViewModel PossibleCommands { get; } + Action? ShowWindow { get; set; } Task RunOrOpenItem(IItemViewModel itemViewModel); } \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/MainWindowViewModel.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/MainWindowViewModel.cs index de77ebd..fd982dd 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/MainWindowViewModel.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/MainWindowViewModel.cs @@ -11,6 +11,7 @@ using FileTime.App.Core.ViewModels.Timeline; using FileTime.App.FrequencyNavigation.Services; using FileTime.Core.Models; using FileTime.Core.Timeline; +using FileTime.GuiApp.App.InstanceManagement; using FileTime.GuiApp.App.Services; using FileTime.Providers.Local; using FileTime.Providers.LocalAdmin; @@ -39,6 +40,7 @@ namespace FileTime.GuiApp.App.ViewModels; [Inject(typeof(IModalService), PropertyName = "_modalService")] [Inject(typeof(ITimelineViewModel), PropertyAccessModifier = AccessModifier.Public)] [Inject(typeof(IPossibleCommandsViewModel), PropertyName = "PossibleCommands", PropertyAccessModifier = AccessModifier.Public)] +[Inject(typeof(IInstanceMessageHandler), PropertyName = "_instanceMessageHandler")] public partial class MainWindowViewModel : IMainWindowViewModel { public bool Loading => false; @@ -48,6 +50,7 @@ public partial class MainWindowViewModel : IMainWindowViewModel public IGuiAppState AppState => _appState; public DeclarativeProperty Title { get; } = new(); public Action? FocusDefaultElement { get; set; } + public Action? ShowWindow { get; set; } partial void OnInitialize() { @@ -72,7 +75,8 @@ public partial class MainWindowViewModel : IMainWindowViewModel Title.SetValueSafe(title); _modalService.AllModalClosed += (_, _) => FocusDefaultElement?.Invoke(); - + _instanceMessageHandler.ShowWindow += () => ShowWindow?.Invoke(); + Task.Run(async () => await _lifecycleService.InitStartupHandlersAsync()).Wait(); } diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/MainWindow.axaml b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/MainWindow.axaml index 8ca6ad5..38705b1 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/MainWindow.axaml +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/MainWindow.axaml @@ -1,25 +1,7 @@ + Title="FileTime" + MinWidth="1000" + MinHeight="800" + d:DataContext="vm:MainWindowDesignViewModel" + d:DesignHeight="450" + d:DesignWidth="800" + x:CompileBindings="True" + x:DataType="vm:IMainWindowViewModelBase" + Background="Transparent" + Closed="Window_OnClosed" + Closing="Window_OnClosing" + ExtendClientAreaToDecorationsHint="True" + FontFamily="{Binding MainFont^, Mode=OneWay}" + Icon="/Assets/filetime.ico" + KeyDown="OnKeyDown" + Opened="OnWindowOpened" + RequestedThemeVariant="Dark" + TransparencyLevelHint="{Binding TransparencyLevelHint}" + mc:Ignorable="d"> @@ -49,69 +49,85 @@ + IsVisible="{Binding Loading, Converter={x:Static BoolConverters.Not}, FallbackValue=False}"> - + - + - + - + - + + Source="{SvgImage /Assets/material/security.svg}" /> + Source="{SvgImage /Assets/material/clipboard-outline.svg}" /> + IsVisible="{Binding ClipboardService.Content.Count, Converter={StaticResource GreaterThanConverter}, ConverterParameter=1}" + Source="{SvgImage /Assets/material/clipboard-multiple-outline.svg}" /> - + + Padding="10" + Background="{DynamicResource ContainerBackgroundBrush}" + CornerRadius="10"> - + - + + Source="{SvgImage /Assets/material/folder.svg}" /> + VerticalAlignment="Center" + Orientation="Horizontal"> + VerticalAlignment="Center" + Text="{Binding FullName}" /> + Text="{Binding Label}" /> + VerticalAlignment="Center" + Orientation="Horizontal"> + Text="{Binding Free, Converter={StaticResource FormatSizeConverter}}" /> + Text=" / " /> + Text="{Binding Size, Converter={StaticResource FormatSizeConverter}}" /> @@ -189,16 +205,20 @@ + Padding="0,10" + Background="{DynamicResource ContainerBackgroundBrush}" + CornerRadius="10"> - + - + + Source="{Binding Container, Converter={StaticResource ItemToImageConverter}}" /> + VerticalAlignment="Center" + Text="{Binding DisplayName}" /> @@ -227,81 +247,29 @@ - + + + - + @@ -325,9 +293,9 @@ + ItemsSource="{Binding TimelineViewModel.ParallelCommandsGroups}"> @@ -335,55 +303,67 @@ - + - - + CornerRadius="10"> + + - - + - + @@ -394,19 +374,21 @@ + Fill="{DynamicResource ContentSeparatorBrush}" /> - + @@ -415,26 +397,32 @@ - + - + + VerticalAlignment="Center" + Text="{Binding CurrentLocation.Value.Name, FallbackValue=Loading...}" /> - + + Fill="{DynamicResource ContentSeparatorBrush}" /> - + + Classes="LoadingAnimation" + Source="{SvgImage /Assets/loading.svg}" /> + SelectedItem="{Binding AppState.SelectedTab.Value.CurrentSelectedItem.Value}"> + IsVisible="{Binding AppState.SelectedTab.Value.CurrentItems.Value.Count, Converter={StaticResource EqualsConverter}, ConverterParameter=0}"> Empty + Fill="{DynamicResource ContentSeparatorBrush}" /> @@ -517,31 +507,33 @@ + Classes="LoadingAnimation" + Source="{SvgImage /Assets/loading.svg}" /> - + + ItemsSource="{Binding AppState.SelectedTab.Value.SelectedsChildren.Value}"> @@ -551,37 +543,43 @@ + IsVisible="{Binding AppState.SelectedTab.Value.SelectedsChildren.Value.Count, Converter={StaticResource EqualsConverter}, ConverterParameter=0}"> Empty - + - + - + - + - + - - - + + + @@ -618,8 +626,12 @@ - - + + @@ -629,36 +641,44 @@ - + - + - + - + @@ -669,15 +689,19 @@ - + - + @@ -690,11 +714,11 @@ + HorizontalAlignment="Center" + VerticalAlignment="Top" + IsVisible="{Binding AppState.PopupTexts.Count, Converter={StaticResource NotEqualsConverter}}" + ItemsSource="{Binding AppState.PopupTexts}">