diff --git a/Directory.Build.props b/Directory.Build.props index e32e82a180..da2760b068 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -85,7 +85,7 @@ false - 202310210737 + 202406130737 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderInformationalPreviewControl.xaml.cs b/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderInformationalPreviewControl.xaml.cs new file mode 100644 index 0000000000..9ae7a610ae --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderInformationalPreviewControl.xaml.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Peek.Common.Helpers; +using Peek.FilePreviewer.Models; + +namespace Peek.FilePreviewer.Controls; + +public sealed partial class SpecialFolderInformationalPreviewControl : UserControl +{ + public static readonly DependencyProperty SourceProperty = DependencyProperty.Register( + nameof(Source), + typeof(SpecialFolderPreviewData), + typeof(SpecialFolderInformationalPreviewControl), + new PropertyMetadata(null)); + + public SpecialFolderPreviewData? Source + { + get { return (SpecialFolderPreviewData)GetValue(SourceProperty); } + set { SetValue(SourceProperty, value); } + } + + public SpecialFolderInformationalPreviewControl() + { + InitializeComponent(); + } + + public string FormatFileType(string? fileType) => FormatField("UnsupportedFile_FileType", fileType); + + public string FormatFileSize(string? fileSize) => FormatField("UnsupportedFile_FileSize", fileSize); + + public string FormatFileDateModified(string? fileDateModified) => FormatField("UnsupportedFile_DateModified", fileDateModified); + + private static string FormatField(string resourceId, string? fieldValue) + { + return string.IsNullOrWhiteSpace(fieldValue) ? string.Empty : ReadableStringHelper.FormatResourceString(resourceId, fieldValue); + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml b/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml new file mode 100644 index 0000000000..ec08d0396d --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml.cs b/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml.cs new file mode 100644 index 0000000000..b1a468c3fa --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Peek.Common.Converters; +using Peek.FilePreviewer.Models; +using Peek.FilePreviewer.Previewers; + +namespace Peek.FilePreviewer.Controls; + +[INotifyPropertyChanged] +public sealed partial class SpecialFolderPreview : UserControl +{ + public static readonly DependencyProperty SourceProperty = DependencyProperty.Register( + nameof(Source), + typeof(SpecialFolderPreviewData), + typeof(SpecialFolderPreview), + new PropertyMetadata(null)); + + public static readonly DependencyProperty LoadingStateProperty = DependencyProperty.Register( + nameof(LoadingState), + typeof(PreviewState), + typeof(SpecialFolderPreview), + new PropertyMetadata(PreviewState.Uninitialized)); + + public SpecialFolderPreviewData? Source + { + get { return (SpecialFolderPreviewData)GetValue(SourceProperty); } + set { SetValue(SourceProperty, value); } + } + + public PreviewState? LoadingState + { + get { return (PreviewState)GetValue(LoadingStateProperty); } + set { SetValue(LoadingStateProperty, value); } + } + + public SpecialFolderPreview() + { + InitializeComponent(); + } + + public Visibility IsVisibleIfStatesMatch(PreviewState? a, PreviewState? b) => VisibilityConverter.Convert(a == b); +} diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 67ffe62d84..5d47fbe962 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -83,6 +83,12 @@ Source="{x:Bind DrivePreviewer.Preview, Mode=OneWay}" Visibility="{x:Bind IsPreviewVisible(DrivePreviewer, Previewer.State), Mode=OneWay}" /> + + Previewer as IDrivePreviewer; + public ISpecialFolderPreviewer? SpecialFolderPreviewer => Previewer as ISpecialFolderPreviewer; + public IUnsupportedFilePreviewer? UnsupportedFilePreviewer => Previewer as IUnsupportedFilePreviewer; public IFileSystemItem Item diff --git a/src/modules/peek/Peek.FilePreviewer/Models/SpecialFolderPreviewData.cs b/src/modules/peek/Peek.FilePreviewer/Models/SpecialFolderPreviewData.cs new file mode 100644 index 0000000000..c6549ba50a --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Models/SpecialFolderPreviewData.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.UI.Xaml.Media; + +namespace Peek.FilePreviewer.Models; + +public partial class SpecialFolderPreviewData : ObservableObject +{ + [ObservableProperty] + private ImageSource? iconPreview; + + [ObservableProperty] + private string? fileName; + + [ObservableProperty] + private string? fileType; + + [ObservableProperty] + private string? fileSize; + + [ObservableProperty] + private string? dateModified; +} diff --git a/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj b/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj index 8ffcb6cc8a..495a21289f 100644 --- a/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj +++ b/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj @@ -21,6 +21,8 @@ + + @@ -42,6 +44,27 @@ + + + 0 + 1 + 50a7e9b0-70ef-11d1-b75a-00a0c90564fe + 0 + tlbimp + false + true + + + 1 + 1 + eab22ac0-30c1-11cf-a7eb-0000c05bae0b + 0 + tlbimp + false + true + + + @@ -71,6 +94,9 @@ MSBuild:Compile + + MSBuild:Compile + diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/ISpecialFolderPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/ISpecialFolderPreviewer.cs new file mode 100644 index 0000000000..a5d663a2e4 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/ISpecialFolderPreviewer.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Peek.FilePreviewer.Models; + +namespace Peek.FilePreviewer.Previewers; + +public interface ISpecialFolderPreviewer : IPreviewer +{ + public SpecialFolderPreviewData? Preview { get; } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs index 158521b3ca..cce79a6235 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs @@ -53,6 +53,10 @@ namespace Peek.FilePreviewer.Previewers { return new DrivePreviewer(item); } + else if (SpecialFolderPreviewer.IsItemSupported(item)) + { + return new SpecialFolderPreviewer(item); + } // Other previewer types check their supported file types here return CreateDefaultPreviewer(item); diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/SpecialFolderPreviewer/Helpers/KnownSpecialFolders.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/SpecialFolderPreviewer/Helpers/KnownSpecialFolders.cs new file mode 100644 index 0000000000..930cc5cc60 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/SpecialFolderPreviewer/Helpers/KnownSpecialFolders.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Peek.Common; +using Peek.Common.Models; + +namespace Peek.FilePreviewer.Previewers; + +public enum KnownSpecialFolder +{ + None, + RecycleBin, +} + +public static class KnownSpecialFolders +{ + private static readonly Lazy> FoldersByParsingNameDict = new(GetFoldersByParsingName); + + public static IReadOnlyDictionary FoldersByParsingName => FoldersByParsingNameDict.Value; + + private static Dictionary GetFoldersByParsingName() + { + var folders = new (KnownSpecialFolder Folder, string? ParsingName)[] + { + (KnownSpecialFolder.RecycleBin, GetParsingName("shell:RecycleBinFolder")), + }; + + return folders.Where(folder => !string.IsNullOrEmpty(folder.ParsingName)) + .ToDictionary(folder => folder.ParsingName!, folder => folder.Folder); + } + + private static string? GetParsingName(string shellName) + { + try + { + return CreateShellItemFromShellName(shellName)?.GetDisplayName(Windows.Win32.UI.Shell.SIGDN.SIGDN_DESKTOPABSOLUTEPARSING); + } + catch (Exception) + { + return null; + } + } + + private static IShellItem? CreateShellItemFromShellName(string shellName) + { + // Based on https://stackoverflow.com/a/42966899 + const string ShellItem = "43826d1e-e718-42ee-bc55-a1e261c37bfe"; + + Guid shellItem2Guid = new(ShellItem); + int retCode = NativeMethods.SHCreateItemFromParsingName(shellName, IntPtr.Zero, ref shellItem2Guid, out IShellItem? nativeShellItem); + + return retCode == 0 ? nativeShellItem : null; + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/SpecialFolderPreviewer/SpecialFolderPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/SpecialFolderPreviewer/SpecialFolderPreviewer.cs new file mode 100644 index 0000000000..124071e54a --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/SpecialFolderPreviewer/SpecialFolderPreviewer.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using ManagedCommon; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media.Imaging; +using Peek.Common.Extensions; +using Peek.Common.Helpers; +using Peek.Common.Models; +using Peek.FilePreviewer.Models; +using Peek.FilePreviewer.Previewers.Helpers; +using Windows.Foundation; + +namespace Peek.FilePreviewer.Previewers; + +public partial class SpecialFolderPreviewer : ObservableObject, ISpecialFolderPreviewer, IDisposable +{ + private readonly DispatcherTimer _syncDetailsDispatcherTimer = new(); + private ulong _folderSize; + private DateTime? _dateModified; + + [ObservableProperty] + private SpecialFolderPreviewData preview = new(); + + [ObservableProperty] + private PreviewState state; + + public SpecialFolderPreviewer(IFileSystemItem file) + { + _syncDetailsDispatcherTimer.Interval = TimeSpan.FromMilliseconds(500); + _syncDetailsDispatcherTimer.Tick += DetailsDispatcherTimer_Tick; + + Item = file; + Preview.FileName = file.Name; + Dispatcher = DispatcherQueue.GetForCurrentThread(); + } + + public static bool IsItemSupported(IFileSystemItem item) + { + // Always allow know special folders. + bool isKnownSpecialFolder = KnownSpecialFolders.FoldersByParsingName.ContainsKey(item.ParsingName); + + // Allow empty paths unless Unc; icons don't load correctly for Unc paths. + bool isEmptyNonUncPath = string.IsNullOrEmpty(item.Path) && !PathHelper.IsUncPath(item.ParsingName); + + return isKnownSpecialFolder || isEmptyNonUncPath; + } + + private IFileSystemItem Item { get; } + + private DispatcherQueue Dispatcher { get; } + + public void Dispose() + { + _syncDetailsDispatcherTimer.Tick -= DetailsDispatcherTimer_Tick; + GC.SuppressFinalize(this); + } + + public Task GetPreviewSizeAsync(CancellationToken cancellationToken) + { + Size? size = new(680, 500); + var previewSize = new PreviewSize { MonitorSize = size, UseEffectivePixels = true }; + return Task.FromResult(previewSize); + } + + public async Task LoadPreviewAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + State = PreviewState.Loading; + + var tasks = await Task.WhenAll(LoadIconPreviewAsync(cancellationToken), LoadDisplayInfoAsync(cancellationToken)); + + State = tasks.All(task => task) ? PreviewState.Loaded : PreviewState.Error; + } + + public async Task CopyAsync() + { + await Dispatcher.RunOnUiThread(async () => + { + var storageItem = await Item.GetStorageItemAsync(); + ClipboardHelper.SaveToClipboard(storageItem); + }); + } + + public async Task LoadIconPreviewAsync(CancellationToken cancellationToken) + { + bool isIconValid = false; + + var isTaskSuccessful = await TaskExtension.RunSafe(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + + await Dispatcher.RunOnUiThread(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + + var iconBitmap = await IconHelper.GetIconAsync(Item.ParsingName, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + isIconValid = iconBitmap != null; + Preview.IconPreview = iconBitmap ?? new SvgImageSource(new Uri("ms-appx:///Assets/Peek/DefaultFileIcon.svg")); + }); + }); + + return isIconValid && isTaskSuccessful; + } + + public async Task LoadDisplayInfoAsync(CancellationToken cancellationToken) + { + bool isDisplayValid = false; + + var isTaskSuccessful = await TaskExtension.RunSafe(async () => + { + cancellationToken.ThrowIfCancellationRequested(); + + var fileType = await Task.Run(Item.GetContentTypeAsync); + + cancellationToken.ThrowIfCancellationRequested(); + + isDisplayValid = fileType != null; + + await Dispatcher.RunOnUiThread(() => + { + Preview.FileType = fileType; + return Task.CompletedTask; + }); + + RunUpdateDetailsWorkflow(cancellationToken); + }); + + return isDisplayValid && isTaskSuccessful; + } + + private void RunUpdateDetailsWorkflow(CancellationToken cancellationToken) + { + Task.Run( + async () => + { + try + { + await Dispatcher.RunOnUiThread(_syncDetailsDispatcherTimer.Start); + ComputeDetails(cancellationToken); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.LogError("Failed to update special folder details", ex); + } + finally + { + await Dispatcher.RunOnUiThread(_syncDetailsDispatcherTimer.Stop); + } + + await Dispatcher.RunOnUiThread(SyncDetails); + }, + cancellationToken); + } + + private void ComputeDetails(CancellationToken cancellationToken) + { + _dateModified = Item.DateModified; + + switch (KnownSpecialFolders.FoldersByParsingName.GetValueOrDefault(Item.ParsingName, KnownSpecialFolder.None)) + { + case KnownSpecialFolder.None: + break; + + case KnownSpecialFolder.RecycleBin: + ThreadHelper.RunOnSTAThread(() => { ComputeRecycleBinDetails(cancellationToken); }); + cancellationToken.ThrowIfCancellationRequested(); + break; + } + } + + private void ComputeRecycleBinDetails(CancellationToken cancellationToken) + { + var shell = new Shell32.Shell(); + var recycleBin = shell.NameSpace(10); // CSIDL_BITBUCKET + + foreach (dynamic item in recycleBin.Items()) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + _folderSize += Convert.ToUInt64(item.Size); + } + } + + private void SyncDetails() + { + Preview.FileSize = _folderSize == 0 ? string.Empty : ReadableStringHelper.BytesToReadableString(_folderSize); + Preview.DateModified = _dateModified?.ToString(CultureInfo.CurrentCulture) ?? string.Empty; + } + + private void DetailsDispatcherTimer_Tick(object? sender, object e) + { + SyncDetails(); + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/UnsupportedFilePreviewer/UnsupportedFilePreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/UnsupportedFilePreviewer/UnsupportedFilePreviewer.cs index 1b6b526bd0..185ca19ee9 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/UnsupportedFilePreviewer/UnsupportedFilePreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/UnsupportedFilePreviewer/UnsupportedFilePreviewer.cs @@ -45,8 +45,6 @@ namespace Peek.FilePreviewer.Previewers Dispatcher = DispatcherQueue.GetForCurrentThread(); } - public bool IsPreviewLoaded => Preview.IconPreview != null; - private IFileSystemItem Item { get; } private DispatcherQueue Dispatcher { get; } diff --git a/src/modules/peek/Peek.UI/Extensions/IShellItemExtensions.cs b/src/modules/peek/Peek.UI/Extensions/IShellItemExtensions.cs index 3d2fd2e57f..7e4eee002b 100644 --- a/src/modules/peek/Peek.UI/Extensions/IShellItemExtensions.cs +++ b/src/modules/peek/Peek.UI/Extensions/IShellItemExtensions.cs @@ -7,49 +7,41 @@ using System.IO; using ManagedCommon; using Peek.Common.Models; -namespace Peek.UI.Extensions +namespace Peek.UI.Extensions; + +public static class IShellItemExtensions { - public static class IShellItemExtensions + public static IFileSystemItem ToIFileSystemItem(this IShellItem shellItem) { - public static IFileSystemItem ToIFileSystemItem(this IShellItem shellItem) - { - string path = shellItem.GetPath(); - string name = shellItem.GetName(); + string path = shellItem.GetPath(); + string name = shellItem.GetName(); - return File.Exists(path) ? new FileItem(path, name) : new FolderItem(path, name); + return File.Exists(path) ? new FileItem(path, name) : new FolderItem(path, name, shellItem.GetParsingName()); + } + + private static string GetPath(this IShellItem shellItem) => + shellItem.GetNameCore(Windows.Win32.UI.Shell.SIGDN.SIGDN_FILESYSPATH, logError: false); + + private static string GetName(this IShellItem shellItem) => + shellItem.GetNameCore(Windows.Win32.UI.Shell.SIGDN.SIGDN_NORMALDISPLAY, logError: true); + + private static string GetParsingName(this IShellItem shellItem) => + shellItem.GetNameCore(Windows.Win32.UI.Shell.SIGDN.SIGDN_DESKTOPABSOLUTEPARSING, logError: true); + + private static string GetNameCore(this IShellItem shellItem, Windows.Win32.UI.Shell.SIGDN displayNameType, bool logError) + { + try + { + return shellItem.GetDisplayName(displayNameType); } - - private static string GetPath(this IShellItem shellItem) + catch (Exception ex) { - string path = string.Empty; - try + if (logError) { - path = shellItem.GetDisplayName(Windows.Win32.UI.Shell.SIGDN.SIGDN_FILESYSPATH); - } - catch (Exception ex) - { - // TODO: Handle cases that do not have a file system path like Recycle Bin. - path = string.Empty; - Logger.LogError("Getting path failed. " + ex.Message); + Logger.LogError($"Getting {Enum.GetName(displayNameType)} failed. {ex.Message}"); } - return path; - } - - private static string GetName(this IShellItem shellItem) - { - string name = string.Empty; - try - { - name = shellItem.GetDisplayName(Windows.Win32.UI.Shell.SIGDN.SIGDN_NORMALDISPLAY); - } - catch (Exception ex) - { - name = string.Empty; - Logger.LogError("Getting path failed. " + ex.Message); - } - - return name; + return string.Empty; } } }