diff --git a/src/Core/FileTime.Core.Abstraction/Helper/PathHelper.cs b/src/Core/FileTime.Core.Abstraction/Helper/PathHelper.cs index 863d969..37ff147 100644 --- a/src/Core/FileTime.Core.Abstraction/Helper/PathHelper.cs +++ b/src/Core/FileTime.Core.Abstraction/Helper/PathHelper.cs @@ -1,3 +1,4 @@ +using System.Collections; using FileTime.Core.Models; namespace FileTime.Core.Helper; @@ -33,6 +34,7 @@ public static class PathHelper return string.Join(Constants.SeparatorChar, commonPathParts); } + public static string GetCommonPath(string? path1, string? path2) { var path1Parts = path1?.Split(Constants.SeparatorChar) ?? Array.Empty(); @@ -51,4 +53,18 @@ public static class PathHelper return string.Join(Constants.SeparatorChar, commonPathParts); } -} + + public static string ReplaceEnvironmentVariablePlaceHolders(string path) + { + foreach (DictionaryEntry environmentVariable in Environment.GetEnvironmentVariables()) + { + var value = environmentVariable.Value?.ToString(); + + if (value is null) continue; + + path = path.Replace($"%{environmentVariable.Key}%", value, StringComparison.OrdinalIgnoreCase); + } + + return path; + } +} \ No newline at end of file diff --git a/src/Core/FileTime.Core.Abstraction/Models/ISymlinkElement.cs b/src/Core/FileTime.Core.Abstraction/Models/ISymlinkElement.cs deleted file mode 100644 index 3da0cce..0000000 --- a/src/Core/FileTime.Core.Abstraction/Models/ISymlinkElement.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace FileTime.Core.Models; - -public interface ISymlinkElement -{ - IItem RealItem { get; } -} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/CloudDrives/CloudDrive.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/CloudDrives/CloudDrive.cs new file mode 100644 index 0000000..7d1302b --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/CloudDrives/CloudDrive.cs @@ -0,0 +1,5 @@ +using FileTime.Core.Models; + +namespace FileTime.GuiApp.App.CloudDrives; + +public record CloudDrive(string Name, NativePath Path); \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/CloudDrives/ICloudDriveService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/CloudDrives/ICloudDriveService.cs new file mode 100644 index 0000000..4ce83fb --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/CloudDrives/ICloudDriveService.cs @@ -0,0 +1,8 @@ +using FileTime.App.Core.Services; + +namespace FileTime.GuiApp.App.CloudDrives; + +public interface ICloudDriveService : IStartupHandler +{ + IReadOnlyList CloudDrives { get; } +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/FileTime.GuiApp.App.Abstractions.csproj b/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/FileTime.GuiApp.App.Abstractions.csproj index 0e09af9..4b3301d 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/FileTime.GuiApp.App.Abstractions.csproj +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/FileTime.GuiApp.App.Abstractions.csproj @@ -28,4 +28,5 @@ + diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/IconProviders/IIconProvider.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/IconProviders/IIconProvider.cs index a5c9bf0..c961d2d 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/IconProviders/IIconProvider.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App.Abstractions/IconProviders/IIconProvider.cs @@ -6,5 +6,6 @@ namespace FileTime.GuiApp.App.IconProviders; public interface IIconProvider { ImagePath GetImage(IItem item); + ImagePath GetImage(string? localPath, bool isContainer, bool isLocalItem); bool EnableAdvancedIcons { get; set; } } \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/CloudDrives/LinuxCloudDriveService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/CloudDrives/LinuxCloudDriveService.cs new file mode 100644 index 0000000..5ad70ce --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/CloudDrives/LinuxCloudDriveService.cs @@ -0,0 +1,10 @@ +using PropertyChanged.SourceGenerator; + +namespace FileTime.GuiApp.App.CloudDrives; + +public partial class LinuxCloudDriveService : ICloudDriveService +{ + [Notify] private IReadOnlyList _cloudDrives = new List(); + + public Task InitAsync() => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/CloudDrives/WindowsCloudDriveService.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/CloudDrives/WindowsCloudDriveService.cs new file mode 100644 index 0000000..0543f1e --- /dev/null +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/CloudDrives/WindowsCloudDriveService.cs @@ -0,0 +1,51 @@ +// Based on: https://github.com/files-community/Files/blob/main/src/Files.App/Utils/Cloud/CloudDrivesDetector.cs + +using System.Runtime.Versioning; +using FileTime.Core.Helper; +using FileTime.Core.Models; +using Microsoft.Win32; +using PropertyChanged.SourceGenerator; + +namespace FileTime.GuiApp.App.CloudDrives; + +[SupportedOSPlatform("windows")] +public sealed partial class WindowsCloudDriveService : ICloudDriveService +{ + [Notify] private IReadOnlyList _cloudDrives = new List(); + + private async Task> GetCloudDrives() + { + var cloudDrives = new List(); + cloudDrives.AddRange(await GetOneDrive()); + cloudDrives.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.Ordinal)); + return cloudDrives; + } + + private static Task> GetOneDrive() + { + using var oneDriveAccountsKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\OneDrive\Accounts"); + if (oneDriveAccountsKey is null) + { + return Task.FromResult(Enumerable.Empty()); + } + + var oneDriveAccounts = new List(); + foreach (var account in oneDriveAccountsKey.GetSubKeyNames()) + { + var accountKeyName = @$"{oneDriveAccountsKey.Name}\{account}"; + var displayName = (string?) Registry.GetValue(accountKeyName, "DisplayName", null); + var userFolder = (string?) Registry.GetValue(accountKeyName, "UserFolder", null); + var accountName = string.IsNullOrWhiteSpace(displayName) ? "OneDrive" : $"OneDrive - {displayName}"; + + if (string.IsNullOrWhiteSpace(userFolder) || oneDriveAccounts.Any(x => x.Name == accountName)) continue; + + userFolder = PathHelper.ReplaceEnvironmentVariablePlaceHolders(userFolder); + + oneDriveAccounts.Add(new CloudDrive(accountName, new NativePath(userFolder))); + } + + return Task.FromResult>(oneDriveAccounts); + } + + public async Task InitAsync() => CloudDrives = (await GetCloudDrives()).AsReadOnly(); +} \ No newline at end of file diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Converters/ItemToImageConverter.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Converters/ItemToImageConverter.cs index fee1bea..b0d4f20 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Converters/ItemToImageConverter.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Converters/ItemToImageConverter.cs @@ -4,40 +4,34 @@ using Avalonia.Svg.Skia; using FileTime.App.Core.ViewModels; using FileTime.Core.Models; using FileTime.GuiApp.App.IconProviders; +using FileTime.GuiApp.App.Models; +using FileTime.Providers.Local; using Microsoft.Extensions.DependencyInjection; namespace FileTime.GuiApp.App.Converters; public class ItemToImageConverter : IValueConverter { - private readonly IIconProvider _iconProvider; - - public ItemToImageConverter() - { - _iconProvider = DI.ServiceProvider.GetRequiredService(); - } + private readonly IIconProvider _iconProvider = DI.ServiceProvider.GetRequiredService(); + private readonly ILocalContentProvider _localContentProvider = DI.ServiceProvider.GetRequiredService(); public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { if (value == null) return null; - IItem item = value switch - { - IContainerViewModel container => container.Container!, - IElementViewModel element => element.Element!, - IItem i => i, - _ => throw new NotImplementedException() - }; - SvgSource? source; try { - var path = _iconProvider.GetImage(item)!; - if (path.Type == Models.ImagePathType.Absolute) + var path = GetImageFromPath(value); + path ??= GetImageFromItem(value); + + if (path is null) return null; + + if (path.Type == ImagePathType.Absolute) { source = SvgSource.Load(path.Path!, null); } - else if (path.Type == Models.ImagePathType.Raw) + else if (path.Type == ImagePathType.Raw) { return path.Image; } @@ -54,6 +48,35 @@ public class ItemToImageConverter : IValueConverter return new SvgImage {Source = source}; } + private ImagePath? GetImageFromItem(object value) + { + var item = value switch + { + IContainerViewModel container => container.Container!, + IElementViewModel element => element.Element!, + IItem i => i, + _ => null + }; + + if (item is null) return null; + + return _iconProvider.GetImage(item)!; + } + + private ImagePath? GetImageFromPath(object value) + { + if (value is not NativePath nativePath) return null; + + var canHandlePathTask = _localContentProvider.CanHandlePathAsync(nativePath); + canHandlePathTask.Wait(); + var isLocal = canHandlePathTask.Result; + + + var isDirectory = Directory.Exists(nativePath.Path); + + return _iconProvider.GetImage(nativePath.Path, isDirectory, isLocal); + } + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { throw new NotImplementedException(); 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 319af6f..085aed8 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/FileTime.GuiApp.App.csproj +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/FileTime.GuiApp.App.csproj @@ -58,6 +58,7 @@ + diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/IconProviders/MaterialIconProvider.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/IconProviders/MaterialIconProvider.cs index b9eb4a9..deaa869 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/IconProviders/MaterialIconProvider.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/IconProviders/MaterialIconProvider.cs @@ -33,32 +33,38 @@ public class MaterialIconProvider : IIconProvider }); } - public ImagePath GetImage(IItem item) + public ImagePath GetImage(IItem item) => GetImage(item.NativePath?.Path, item is IContainer, item.Provider is ILocalContentProvider); + + public ImagePath GetImage(string? localPath, bool isContainer, bool isLocalItem) { - item = item is ISymlinkElement symlinkElement ? symlinkElement.RealItem : item; - var icon = item is IContainer ? "folder.svg" : "file.svg"; - var localPath = item.NativePath?.Path.TrimEnd(Path.DirectorySeparatorChar); + var icon = isContainer ? "folder.svg" : "file.svg"; + localPath = localPath?.TrimEnd(Path.DirectorySeparatorChar); if (!EnableAdvancedIcons) return GetAssetPath(icon); - if (localPath != null && _specialPaths.Value.Find(p => p.Path == localPath) is SpecialPathWithIcon specialPath) + if (_specialPaths.Value.Find(p => p.Path == localPath) is { } specialPath) { return specialPath.IconPath; } - if (item is not IElement element) return GetAssetPath(icon); + if (isContainer || localPath is null) return GetAssetPath(icon); - if (element.Provider is ILocalContentProvider && (localPath?.EndsWith(".svg") ?? false)) + if (isLocalItem && localPath.EndsWith(".svg")) { return new ImagePath(ImagePathType.Absolute, localPath); } - string? possibleIcon = null; - var fileName = element.Name; - var extension = element.Name.Contains('.') ? element.Name.Split('.').Last() : null; + string? possibleIcon; + var fileName = localPath.Split(Path.DirectorySeparatorChar).Last(); - if (_iconsByFileName.TryGetValue(fileName, out var value)) possibleIcon = value; - else if (_iconsByExtension.FirstOrDefault(k => fileName.EndsWith("." + k.Key)) is KeyValuePair {Key: { }} matchingExtension) possibleIcon = matchingExtension.Value; + if (_iconsByFileName.TryGetValue(fileName, out var value)) + { + possibleIcon = value; + } + else if (_iconsByExtension.FirstOrDefault(k => fileName.EndsWith("." + k.Key)) is var matchingExtension) + { + possibleIcon = matchingExtension.Value; + } if (possibleIcon != null) { diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/IMainWindowViewModel.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/IMainWindowViewModel.cs index bb17154..708c2fa 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/IMainWindowViewModel.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/IMainWindowViewModel.cs @@ -4,6 +4,7 @@ using FileTime.App.Core.Services; using FileTime.App.Core.ViewModels; using FileTime.App.Core.ViewModels.Timeline; using FileTime.App.FrequencyNavigation.Services; +using FileTime.GuiApp.App.CloudDrives; using FileTime.GuiApp.App.Services; using FileTime.Providers.LocalAdmin; @@ -21,6 +22,7 @@ public interface IMainWindowViewModel : IMainWindowViewModelBase IClipboardService ClipboardService { get; } ITimelineViewModel TimelineViewModel { get; } IPossibleCommandsViewModel PossibleCommands { get; } + ICloudDriveService CloudDriveService { get; } Action? ShowWindow { get; set; } Thickness IconStatusPanelMargin { get; } Task RunOrOpenItem(IItemViewModel itemViewModel); diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/MainWindowViewModel.cs b/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/MainWindowViewModel.cs index 2a20832..1ec2473 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/MainWindowViewModel.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/ViewModels/MainWindowViewModel.cs @@ -13,6 +13,7 @@ using FileTime.App.Core.ViewModels.Timeline; using FileTime.App.FrequencyNavigation.Services; using FileTime.Core.Models; using FileTime.Core.Timeline; +using FileTime.GuiApp.App.CloudDrives; using FileTime.GuiApp.App.InstanceManagement; using FileTime.GuiApp.App.Services; using FileTime.Providers.Local; @@ -43,6 +44,7 @@ namespace FileTime.GuiApp.App.ViewModels; [Inject(typeof(ITimelineViewModel), PropertyAccessModifier = AccessModifier.Public)] [Inject(typeof(IPossibleCommandsViewModel), PropertyName = "PossibleCommands", PropertyAccessModifier = AccessModifier.Public)] [Inject(typeof(IInstanceMessageHandler), PropertyName = "_instanceMessageHandler")] +[Inject(typeof(ICloudDriveService), PropertyAccessModifier = AccessModifier.Public)] public partial class MainWindowViewModel : IMainWindowViewModel { public bool Loading => false; @@ -57,7 +59,7 @@ public partial class MainWindowViewModel : IMainWindowViewModel partial void OnInitialize() { - _logger?.LogInformation($"Starting {nameof(MainWindowViewModel)} initialization..."); + _logger.LogInformation($"Starting {nameof(MainWindowViewModel)} initialization..."); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/MainWindow.axaml b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/MainWindow.axaml index b979686..00beeb8 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/MainWindow.axaml +++ b/src/GuiApp/Avalonia/FileTime.GuiApp.App/Views/MainWindow.axaml @@ -3,6 +3,7 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:appCoreModels="using:FileTime.App.Core.Models" + xmlns:cloudDrives="clr-namespace:FileTime.GuiApp.App.CloudDrives;assembly=FileTime.GuiApp.App.Abstractions" xmlns:corevm="using:FileTime.App.Core.ViewModels" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:i="clr-namespace:Avalonia.Xaml.Interactivity;assembly=Avalonia.Xaml.Interactivity" @@ -116,13 +117,13 @@ + CornerRadius="10" + IsVisible="{Binding AppState.Places.Count, Converter={StaticResource GreaterThanConverter}, ConverterParameter=0}"> + + + + + + + + + + + + + + + + + + + + + (); + path = await timelessContentProvider.GetFullNameByNativePathAsync(cloudDrivePath); + } if (path is null) return; diff --git a/src/GuiApp/Avalonia/FileTime.GuiApp/Startup.cs b/src/GuiApp/Avalonia/FileTime.GuiApp/Startup.cs index bdc0376..6a747f6 100644 --- a/src/GuiApp/Avalonia/FileTime.GuiApp/Startup.cs +++ b/src/GuiApp/Avalonia/FileTime.GuiApp/Startup.cs @@ -5,6 +5,7 @@ using FileTime.App.Core.Configuration; using FileTime.App.Core.Services; using FileTime.App.Core.ViewModels; using FileTime.Core.Interactions; +using FileTime.GuiApp.App.CloudDrives; using FileTime.GuiApp.App.Configuration; using FileTime.GuiApp.App.ContextMenu; using FileTime.GuiApp.CustomImpl.ViewModels; @@ -84,18 +85,21 @@ public static class Startup { serviceCollection .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); } else { serviceCollection .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); } return serviceCollection - .AddSingleton() - .AddSingleton(sp => sp.GetRequiredService()); + .AddSingleton(sp => sp.GetRequiredService()) + .AddSingleton(sp => sp.GetRequiredService()) + .AddSingleton(sp => sp.GetRequiredService()); } internal static IServiceCollection RegisterLogging(this IServiceCollection serviceCollection) diff --git a/src/Providers/FileTime.Providers.Local.Abstractions/IRootDriveInfoService.cs b/src/Providers/FileTime.Providers.Local.Abstractions/IRootDriveInfoService.cs index 331ed11..8cb1593 100644 --- a/src/Providers/FileTime.Providers.Local.Abstractions/IRootDriveInfoService.cs +++ b/src/Providers/FileTime.Providers.Local.Abstractions/IRootDriveInfoService.cs @@ -1,8 +1,9 @@ using System.Collections.ObjectModel; +using FileTime.App.Core.Services; namespace FileTime.Providers.Local; -public interface IRootDriveInfoService +public interface IRootDriveInfoService : IExitHandler { ObservableCollection RootDriveInfos { get; set; } } \ No newline at end of file diff --git a/src/Providers/FileTime.Providers.Local/RootDriveInfoService.cs b/src/Providers/FileTime.Providers.Local/RootDriveInfoService.cs index 53f8765..a6ccf2c 100644 --- a/src/Providers/FileTime.Providers.Local/RootDriveInfoService.cs +++ b/src/Providers/FileTime.Providers.Local/RootDriveInfoService.cs @@ -1,12 +1,11 @@ using System.Collections.ObjectModel; using System.Runtime.InteropServices; -using FileTime.App.Core.Services; using FileTime.Core.Models; using ObservableComputations; namespace FileTime.Providers.Local; -public class RootDriveInfoService : IRootDriveInfoService, IExitHandler +public class RootDriveInfoService : IRootDriveInfoService { private readonly ILocalContentProvider _localContentProvider; private readonly List _rootDrives = new();