Multi instance, tab handling
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
using FileTime.Core.Models;
|
||||
|
||||
namespace FileTime.App.Core.Models;
|
||||
|
||||
public record TabToOpen(int? TabNumber, NativePath Path);
|
||||
|
||||
public record TabsToOpenOnStart(List<TabToOpen> TabsToOpen);
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -47,19 +47,15 @@ public class MainConfiguration
|
||||
private static List<CommandBindingConfiguration> InitDefaultKeyBindings() =>
|
||||
new List<CommandBindingConfiguration>
|
||||
{
|
||||
//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)}),
|
||||
|
||||
@@ -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<TabPersistenceService> logger)
|
||||
{
|
||||
_appState = appState;
|
||||
@@ -53,6 +55,7 @@ public class TabPersistenceService : ITabPersistenceService
|
||||
_serviceProvider = serviceProvider;
|
||||
_localContentProvider = localContentProvider;
|
||||
_tabPersistenceSettings = tabPersistenceSettings;
|
||||
_tabsToOpen = tabsToOpen;
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
@@ -62,7 +65,43 @@ 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<ITabViewModel>();
|
||||
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<ITab>();
|
||||
var tabViewModel = _serviceProvider.GetInitableResolver(tab, tabNumber).GetRequiredService<ITabViewModel>();
|
||||
|
||||
_appState.AddTab(tabViewModel);
|
||||
tabViewModels.Add(tabViewModel);
|
||||
}
|
||||
|
||||
tabViewModels.Reverse();
|
||||
await _appState.SetSelectedTabAsync(tabViewModels.Concat(loadedTabViewModels).First());
|
||||
}
|
||||
|
||||
public Task ExitAsync(CancellationToken token = default)
|
||||
{
|
||||
@@ -72,12 +111,17 @@ public class TabPersistenceService : ITabPersistenceService
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task LoadStatesAsync(CancellationToken token = default)
|
||||
private async Task<IEnumerable<ITabViewModel>> 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<ITabViewModel>();
|
||||
}
|
||||
|
||||
try
|
||||
@@ -86,7 +130,8 @@ public class TabPersistenceService : ITabPersistenceService
|
||||
var state = await JsonSerializer.DeserializeAsync<PersistenceRoot>(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<ITabViewModel>();
|
||||
|
||||
async Task<ITabViewModel> CreateEmptyTab()
|
||||
{
|
||||
IContainer? currentDirectory = null;
|
||||
try
|
||||
@@ -116,19 +167,19 @@ public class TabPersistenceService : ITabPersistenceService
|
||||
var tabViewModel = _serviceProvider.GetInitableResolver(tab, 1).GetRequiredService<ITabViewModel>();
|
||||
|
||||
_appState.AddTab(tabViewModel);
|
||||
|
||||
return tabViewModel;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> RestoreTabs(TabStates? tabStates)
|
||||
private async Task<(bool Success, IEnumerable<ITabViewModel>)> RestoreTabs(TabStates? tabStates)
|
||||
{
|
||||
if (tabStates == null
|
||||
|| tabStates.Tabs == null)
|
||||
{
|
||||
return false;
|
||||
return (false, Enumerable.Empty<ITabViewModel>());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var tab in tabStates.Tabs)
|
||||
{
|
||||
try
|
||||
@@ -183,14 +234,8 @@ public class TabPersistenceService : ITabPersistenceService
|
||||
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<ITabViewModel>());
|
||||
|
||||
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)
|
||||
|
||||
@@ -86,11 +86,14 @@ public class NavigationUserCommandHandlerService : UserCommandHandlerServiceBase
|
||||
new TypeUserCommandHandler<MoveCursorToLastCommand>(MoveCursorToLast),
|
||||
new TypeUserCommandHandler<MoveCursorUpCommand>(MoveCursorUp),
|
||||
new TypeUserCommandHandler<MoveCursorUpPageCommand>(MoveCursorUpPage),
|
||||
new TypeUserCommandHandler<NewTabCommand>(NewTabAsync),
|
||||
new TypeUserCommandHandler<OpenCommandPaletteCommand>(OpenCommandPalette),
|
||||
new TypeUserCommandHandler<OpenContainerCommand>(OpenContainer),
|
||||
new TypeUserCommandHandler<OpenSelectedCommand>(OpenSelected),
|
||||
new TypeUserCommandHandler<RunOrOpenCommand>(RunOrOpen),
|
||||
new TypeUserCommandHandler<RefreshCommand>(Refresh),
|
||||
new TypeUserCommandHandler<SelectNextTabCommand>(SelectNextTab),
|
||||
new TypeUserCommandHandler<SelectPreviousTabCommand>(SelectPreviousTab),
|
||||
new TypeUserCommandHandler<SwitchToTabCommand>(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<ITab>();
|
||||
var newTabViewModel = _serviceProvider.GetInitableResolver(tab, number).GetRequiredService<ITabViewModel>();
|
||||
|
||||
_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<IContainer> 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<ITabViewModel> CreateTabAsync(IContainer newLocation, int tabNumber)
|
||||
{
|
||||
var tab = await _serviceProvider.GetAsyncInitableResolver(newLocation)
|
||||
.GetRequiredServiceAsync<ITab>();
|
||||
var newTabViewModel = _serviceProvider.GetInitableResolver(tab, tabNumber).GetRequiredService<ITabViewModel>();
|
||||
|
||||
_appState.AddTab(newTabViewModel);
|
||||
|
||||
return newTabViewModel;
|
||||
}
|
||||
|
||||
private Task CloseTab()
|
||||
{
|
||||
if ((!_applicationConfiguration.AllowCloseLastTab && _appState.Tabs.Count < 2) || _selectedTab == null) return Task.CompletedTask;
|
||||
|
||||
@@ -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<IPossibleCommandsService, PossibleCommandsService>();
|
||||
serviceCollection.TryAddSingleton<IPossibleCommandsViewModel, PossibleCommandsViewModel>();
|
||||
serviceCollection.TryAddSingleton<IProgramsService, ProgramsService>();
|
||||
serviceCollection.TryAddSingleton(new TabsToOpenOnStart(new List<TabToOpen>()));
|
||||
|
||||
return serviceCollection
|
||||
.AddCommandHandlers()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,12 +25,21 @@
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.4" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.4" />
|
||||
<PackageReference Include="Avalonia.Svg.Skia" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Svg.Skia" Version="11.0.0.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.4" />
|
||||
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.2" />
|
||||
<PackageReference Include="MessagePack" Version="2.5.124" />
|
||||
<PackageReference Include="MessagePackAnalyzer" Version="2.5.124">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
|
||||
<PackageReference Include="MvvmGen" Version="1.2.1" />
|
||||
<PackageReference Include="PropertyChanged.SourceGenerator" Version="1.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="7.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||
<PackageReference Include="Syroot.Windows.IO.KnownFolders" Version="1.3.0" />
|
||||
@@ -48,4 +57,7 @@
|
||||
<ProjectReference Include="..\FileTime.GuiApp.Font.Abstractions\FileTime.GuiApp.Font.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>(T message, CancellationToken token = default) where T : class, IInstanceMessage;
|
||||
Task<bool> TryConnectAsync(CancellationToken token = default);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<NamedPipeServerStream> _pipeServerStreams = new();
|
||||
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public InstanceManager(
|
||||
IInstanceMessageHandler instanceMessageHandler,
|
||||
ILogger<InstanceManager> logger)
|
||||
{
|
||||
_instanceMessageHandler = instanceMessageHandler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public InstanceManager(
|
||||
IInstanceMessageHandler instanceMessageHandler,
|
||||
ILogger logger)
|
||||
{
|
||||
_instanceMessageHandler = instanceMessageHandler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> 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>(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<IInstanceMessage> ReadMessageAsync(Stream stream, CancellationToken token = default)
|
||||
=> await MessagePackSerializer.DeserializeAsync<IInstanceMessage>(stream, cancellationToken: token);
|
||||
|
||||
private static async Task WriteMessageAsync(Stream stream, IInstanceMessage message, CancellationToken token = default)
|
||||
=> await MessagePackSerializer.SerializeAsync(stream, message, cancellationToken: token);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
namespace FileTime.GuiApp.App.InstanceManagement.Messages;
|
||||
|
||||
[MessagePack.Union(0, typeof(OpenContainers))]
|
||||
public interface IInstanceMessage
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace FileTime.GuiApp.App.InstanceManagement.Messages;
|
||||
|
||||
[MessagePackObject]
|
||||
public class OpenContainers : IInstanceMessage
|
||||
{
|
||||
public OpenContainers()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public OpenContainers(IEnumerable<string> containers)
|
||||
{
|
||||
Containers.AddRange(containers);
|
||||
}
|
||||
|
||||
[Key(0)]
|
||||
public List<string> Containers { get; set; } = new();
|
||||
}
|
||||
@@ -71,10 +71,15 @@ public class SystemClipboardService : ISystemClipboardService
|
||||
return;
|
||||
}
|
||||
|
||||
var fileNativePaths = files
|
||||
.Select(i => _timelessContentProvider.GetNativePathByFullNameAsync(i))
|
||||
.Where(i => i != null)
|
||||
.OfType<NativePath>();
|
||||
var fileNativePaths = new List<NativePath>();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fileNativePath = await _timelessContentProvider.GetNativePathByFullNameAsync(file);
|
||||
if (fileNativePath is not null)
|
||||
{
|
||||
fileNativePaths.Add(fileNativePath);
|
||||
}
|
||||
}
|
||||
|
||||
var targetFiles = new List<IStorageFile>();
|
||||
foreach (var fileNativePath in fileNativePaths)
|
||||
|
||||
@@ -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<DefaultBrowserRegister> _logger;
|
||||
|
||||
public DefaultBrowserRegister(ILogger<DefaultBrowserRegister> 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("-", ",");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace FileTime.GuiApp.App.Settings;
|
||||
|
||||
public interface IDefaultBrowserRegister
|
||||
{
|
||||
void RegisterAsDefaultEditor();
|
||||
void UnregisterAsDefaultEditor();
|
||||
|
||||
void RegisterWinEShortcut();
|
||||
void UnregisterWinEShortcut();
|
||||
bool IsWinEShortcut();
|
||||
bool IsDefaultFileBrowser();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace FileTime.GuiApp.App.Settings;
|
||||
|
||||
public interface ISettingsViewModel
|
||||
{
|
||||
SettingsPane SelectedPane { get; set; }
|
||||
bool SetAsDefaultIsChecked { get; set; }
|
||||
List<SettingsPaneItem> PaneItems { get; }
|
||||
SettingsPaneItem SelectedPaneItem { get; set; }
|
||||
bool SetAsWinEHandlerIsChecked { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace FileTime.GuiApp.App.Settings;
|
||||
|
||||
public enum SettingsPane
|
||||
{
|
||||
Home,
|
||||
Advanced
|
||||
}
|
||||
@@ -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<SettingsPaneItem> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string> Title { get; } = new();
|
||||
public Action? FocusDefaultElement { get; set; }
|
||||
public Action? ShowWindow { get; set; }
|
||||
|
||||
partial void OnInitialize()
|
||||
{
|
||||
@@ -72,6 +75,7 @@ public partial class MainWindowViewModel : IMainWindowViewModel
|
||||
Title.SetValueSafe(title);
|
||||
|
||||
_modalService.AllModalClosed += (_, _) => FocusDefaultElement?.Invoke();
|
||||
_instanceMessageHandler.ShowWindow += () => ShowWindow?.Invoke();
|
||||
|
||||
Task.Run(async () => await _lifecycleService.InitStartupHandlersAsync()).Wait();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using FileTime.App.Core.Services;
|
||||
using FileTime.App.Core.ViewModels;
|
||||
using FileTime.Core.Models;
|
||||
using FileTime.GuiApp.App.Services;
|
||||
using FileTime.GuiApp.App.Settings;
|
||||
using FileTime.GuiApp.App.ViewModels;
|
||||
using FileTime.Providers.Local;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -68,6 +70,7 @@ public partial class MainWindow : Window, IUiAccessor
|
||||
{
|
||||
var viewModel = DI.ServiceProvider.GetRequiredService<MainWindowViewModel>();
|
||||
viewModel.FocusDefaultElement = () => Focus();
|
||||
viewModel.ShowWindow = Activate;
|
||||
ViewModel = viewModel;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -128,17 +131,6 @@ public partial class MainWindow : Window, IUiAccessor
|
||||
{
|
||||
path = placeInfoPath;
|
||||
}
|
||||
/*else if (control.DataContext is IElement element && element.GetParent() is IContainer parentContainer)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
await ViewModel.AppState.SelectedTab.OpenContainer(parentContainer);
|
||||
await ViewModel.AppState.SelectedTab.SetCurrentSelectedItem(element);
|
||||
});
|
||||
});
|
||||
}*/
|
||||
|
||||
if (path is null) return;
|
||||
|
||||
@@ -253,4 +245,13 @@ public partial class MainWindow : Window, IUiAccessor
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void SettingsButton_OnClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var settingsWindow = new SettingsWindow
|
||||
{
|
||||
DataContext = DI.ServiceProvider.GetRequiredService<ISettingsViewModel>()
|
||||
};
|
||||
settingsWindow.ShowDialog(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Window
|
||||
Background="{DynamicResource AppBackgroundColor}"
|
||||
Height="500"
|
||||
Title="Settings"
|
||||
Width="600"
|
||||
d:DesignHeight="450"
|
||||
d:DesignWidth="800"
|
||||
mc:Ignorable="d"
|
||||
x:Class="FileTime.GuiApp.App.Views.SettingsWindow"
|
||||
x:CompileBindings="True"
|
||||
x:DataType="settings:ISettingsViewModel"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:settings="clr-namespace:FileTime.GuiApp.App.Settings"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Grid RowDefinitions="Auto * Auto">
|
||||
<TabStrip
|
||||
HorizontalAlignment="Center"
|
||||
ItemsSource="{Binding PaneItems}"
|
||||
SelectedItem="{Binding SelectedPaneItem}">
|
||||
<TabStrip.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Header}" />
|
||||
</DataTemplate>
|
||||
</TabStrip.ItemTemplate>
|
||||
</TabStrip>
|
||||
<Grid Grid.Row="1" Margin="10">
|
||||
<StackPanel IsVisible="{Binding SelectedPane, Converter={StaticResource EqualsConverter}, ConverterParameter={x:Static settings:SettingsPane.Advanced}}" VerticalAlignment="Top">
|
||||
|
||||
<ToggleSwitch IsChecked="{Binding SetAsDefaultIsChecked, Mode=TwoWay}">
|
||||
<ToggleSwitch.OnContent>Set as default file browser</ToggleSwitch.OnContent>
|
||||
<ToggleSwitch.OffContent>Set as default file browser</ToggleSwitch.OffContent>
|
||||
</ToggleSwitch>
|
||||
|
||||
<ToggleSwitch IsChecked="{Binding SetAsWinEHandlerIsChecked, Mode=TwoWay}">
|
||||
<ToggleSwitch.OnContent>Set Win+E shortcut</ToggleSwitch.OnContent>
|
||||
<ToggleSwitch.OffContent>Set Win+E shortcut</ToggleSwitch.OffContent>
|
||||
</ToggleSwitch>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace FileTime.GuiApp.App.Views;
|
||||
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
public SettingsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using FileTime.App.CommandPalette;
|
||||
using FileTime.App.ContainerSizeScanner;
|
||||
using FileTime.App.Core.Models;
|
||||
using FileTime.App.DependencyInjection;
|
||||
using FileTime.App.FrequencyNavigation;
|
||||
using FileTime.App.Search;
|
||||
using FileTime.Core.Models;
|
||||
using FileTime.GuiApp.App;
|
||||
using FileTime.GuiApp.App.Font;
|
||||
using FileTime.GuiApp.App.ViewModels;
|
||||
@@ -21,7 +24,7 @@ public class Application : Avalonia.Application
|
||||
private static void InitializeApp()
|
||||
{
|
||||
var configuration = Startup.CreateConfiguration();
|
||||
DI.ServiceProvider = DependencyInjection
|
||||
var services = DependencyInjection
|
||||
.RegisterDefaultServices(configuration: configuration)
|
||||
.AddServerCoreServices()
|
||||
.AddFrequencyNavigation()
|
||||
@@ -33,8 +36,21 @@ public class Application : Avalonia.Application
|
||||
.RegisterLogging()
|
||||
.RegisterGuiServices()
|
||||
.AddSettings()
|
||||
.AddViewModels()
|
||||
.BuildServiceProvider();
|
||||
.AddViewModels();
|
||||
|
||||
if (Program.DirectoriesToOpen.Count > 0)
|
||||
{
|
||||
services.AddSingleton(
|
||||
new TabsToOpenOnStart(
|
||||
Program
|
||||
.DirectoriesToOpen
|
||||
.Select(d => new TabToOpen(null, new NativePath(d)))
|
||||
.ToList()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
DI.ServiceProvider = services.BuildServiceProvider();
|
||||
|
||||
var logger = DI.ServiceProvider.GetRequiredService<ILogger<Application>>();
|
||||
logger.LogInformation("App initialization completed");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
@@ -7,15 +8,22 @@ using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.ReactiveUI;
|
||||
using FileTime.App.Core;
|
||||
using FileTime.GuiApp.App.InstanceManagement;
|
||||
using FileTime.GuiApp.App.InstanceManagement.Messages;
|
||||
using Serilog;
|
||||
using Serilog.Debugging;
|
||||
using Serilog.Extensions.Logging;
|
||||
|
||||
namespace FileTime.GuiApp;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static string AppDataRoot { get; private set; }
|
||||
public static string EnvironmentName { get; private set; }
|
||||
public static string AppDataRoot { get; private set; } = null!;
|
||||
public static string EnvironmentName { get; private set; } = null!;
|
||||
|
||||
private static ILogger _logger = null!;
|
||||
|
||||
internal static List<string> DirectoriesToOpen { get; } = new();
|
||||
|
||||
private static void InitLogging()
|
||||
{
|
||||
@@ -37,13 +45,15 @@ public static class Program
|
||||
rollingInterval: RollingInterval.Day,
|
||||
rollOnFileSizeLimit: true)
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
_logger = Log.ForContext(typeof(Program));
|
||||
}
|
||||
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
#if DEBUG
|
||||
(AppDataRoot, EnvironmentName) = Init.InitDevelopment();
|
||||
@@ -55,8 +65,53 @@ public static class Program
|
||||
|
||||
InitLogging();
|
||||
|
||||
Log.Logger.Information("Early app starting...");
|
||||
_logger.Information("Early app starting...");
|
||||
_logger.Information("Args ({ArgsLength}): {Args}", args.Length, $"\"{string.Join("\", \"", args)}\"");
|
||||
|
||||
if (!await CheckDirectoryArguments(args))
|
||||
{
|
||||
NormalStartup(args);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> CheckDirectoryArguments(string[] args)
|
||||
{
|
||||
var directoryArguments = new List<string>();
|
||||
foreach (var path in args)
|
||||
{
|
||||
var directory = new DirectoryInfo(path);
|
||||
if (directory.Exists)
|
||||
{
|
||||
directoryArguments.Add(directory.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
if (directoryArguments.Count == 0) return false;
|
||||
|
||||
var loggerFactory = new SerilogLoggerFactory(Log.Logger);
|
||||
var logger = loggerFactory.CreateLogger(typeof(InstanceManager).Name);
|
||||
var instanceManager = new InstanceManager(new DummyInstanceMessageHandler(), logger);
|
||||
|
||||
try
|
||||
{
|
||||
if (await instanceManager.TryConnectAsync())
|
||||
{
|
||||
await instanceManager.SendMessageAsync(new OpenContainers(directoryArguments));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
DirectoriesToOpen.AddRange(directoryArguments);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void NormalStartup(string[] args)
|
||||
{
|
||||
AppDomain.CurrentDomain.FirstChanceException += OnFirstChanceException;
|
||||
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += OnTaskSchedulerUnobservedTaskException;
|
||||
@@ -73,6 +128,7 @@ public static class Program
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
@@ -91,8 +147,9 @@ public static class Program
|
||||
private static void OnFirstChanceException(object? sender, FirstChanceExceptionEventArgs e)
|
||||
=> HandleUnhandledException(sender, e.Exception);
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
private static void HandleUnhandledException(object? sender, Exception? ex, [CallerMemberName] string caller = "")
|
||||
=> Log.Warning(
|
||||
=> _logger.Debug(
|
||||
ex,
|
||||
"An unhandled exception come from '{Caller}' exception handler from an object of type '{Type}' and value '{Value}': {Exception}",
|
||||
caller,
|
||||
|
||||
@@ -7,8 +7,10 @@ using FileTime.App.Core.ViewModels;
|
||||
using FileTime.Core.Interactions;
|
||||
using FileTime.GuiApp.CustomImpl.ViewModels;
|
||||
using FileTime.GuiApp.App.IconProviders;
|
||||
using FileTime.GuiApp.App.InstanceManagement;
|
||||
using FileTime.GuiApp.App.Logging;
|
||||
using FileTime.GuiApp.App.Services;
|
||||
using FileTime.GuiApp.App.Settings;
|
||||
using FileTime.GuiApp.App.ViewModels;
|
||||
using FileTime.Providers.Local;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -46,6 +48,7 @@ public static class Startup
|
||||
serviceCollection.TryAddSingleton<GuiAppState>();
|
||||
serviceCollection.TryAddSingleton<IAppState>(s => s.GetRequiredService<GuiAppState>());
|
||||
serviceCollection.TryAddSingleton<IGuiAppState>(s => s.GetRequiredService<GuiAppState>());
|
||||
serviceCollection.TryAddSingleton<ISettingsViewModel, SettingsViewModel>();
|
||||
return serviceCollection;
|
||||
}
|
||||
|
||||
@@ -67,6 +70,11 @@ public static class Startup
|
||||
serviceCollection.TryAddSingleton<IUserCommunicationService>(s => s.GetRequiredService<IDialogService>());
|
||||
serviceCollection.TryAddSingleton<IAppKeyService<Key>, GuiAppKeyService>();
|
||||
serviceCollection.TryAddSingleton(new ApplicationConfiguration(false));
|
||||
serviceCollection.TryAddSingleton<IDefaultBrowserRegister, DefaultBrowserRegister>();
|
||||
serviceCollection.TryAddSingleton<IInstanceManager, InstanceManager>();
|
||||
serviceCollection.TryAddSingleton<IInstanceMessageHandler, InstanceMessageHandler>();
|
||||
serviceCollection.AddSingleton<IStartupHandler>(sp => sp.GetRequiredService<IInstanceManager>());
|
||||
serviceCollection.AddSingleton<IExitHandler>(sp => sp.GetRequiredService<IInstanceManager>());
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
|
||||
<PackageReference Include="PropertyChanged.SourceGenerator" Version="1.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
Reference in New Issue
Block a user