diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 9943a6962d87..c3bbe50c61fd 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -102,6 +102,16 @@ LoadingState="{x:Bind UnsupportedFilePreviewer.State, Mode=OneWay}" Source="{x:Bind UnsupportedFilePreviewer.Preview, Mode=OneWay}" Visibility="{x:Bind IsUnsupportedPreviewVisible(UnsupportedFilePreviewer, Previewer.State), Mode=OneWay}" /> + + await ((FilePreview)d).OnScalingFactorPropertyChanged())); + [ObservableProperty] + private int numberOfFiles; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ImagePreviewer))] [NotifyPropertyChangedFor(nameof(VideoPreviewer))] @@ -158,6 +161,8 @@ private async Task OnItemPropertyChanged() // Clear up any unmanaged resources before creating a new previewer instance. (Previewer as IDisposable)?.Dispose(); + NoMoreFiles.Visibility = NumberOfFiles == 0 ? Visibility.Visible : Visibility.Collapsed; + if (Item == null) { Previewer = null; diff --git a/src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs b/src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs new file mode 100644 index 000000000000..8c062a80ce6b --- /dev/null +++ b/src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs @@ -0,0 +1,100 @@ +// 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.Collections.Generic; +using System.Globalization; +using System.Text; +using ManagedCommon; +using static Peek.Common.Helpers.ResourceLoaderInstance; + +namespace Peek.UI.Helpers; + +public static class DeleteErrorMessageHelper +{ + /// + /// The "Could not delete 'filename'." message, which begins every user-facing error string. + /// + private static readonly CompositeFormat UserMessagePrefix = + CompositeFormat.Parse(ResourceLoader.GetString("DeleteFileError_Prefix") + " "); + + /// + /// The message displayed if the delete failed but the error code isn't covered in the + /// collection. + /// + private static readonly string GenericErrorMessage = ResourceLoader.GetString("DeleteFileError_Generic"); + + /// + /// The collection of the most common error codes with their matching log messages and user- + /// facing descriptions. + /// + private static readonly Dictionary DeleteFileErrors = new() + { + { + 2, + ( + "The system cannot find the file specified.", + ResourceLoader.GetString("DeleteFileError_NotFound") + ) + }, + { + 3, + ( + "The system cannot find the path specified.", + ResourceLoader.GetString("DeleteFileError_NotFound") + ) + }, + { + 5, + ( + "Access is denied.", + ResourceLoader.GetString("DeleteFileError_AccessDenied") + ) + }, + { + 19, + ( + "The media is write protected.", + ResourceLoader.GetString("DeleteFileError_WriteProtected") + ) + }, + { + 32, + ( + "The process cannot access the file because it is being used by another process.", + ResourceLoader.GetString("DeleteFileError_FileInUse") + ) + }, + { + 33, + ( + "The process cannot access the file because another process has locked a portion of the file.", + ResourceLoader.GetString("DeleteFileError_FileInUse") + ) + }, + }; + + /// + /// Logs an error message in response to a failed file deletion attempt. + /// + /// The error code returned from the delete call. + public static void LogError(int errorCode) => + Logger.LogError(DeleteFileErrors.TryGetValue(errorCode, out var messages) ? + messages.LogMessage : + $"Error {errorCode} occurred while deleting the file."); + + /// + /// Gets the message to display in the UI for a specific delete error code. + /// + /// The name of the file which could not be deleted. + /// The error code result from the delete call. + /// A string containing the message to show in the user interface. + public static string GetUserErrorMessage(string filename, int errorCode) + { + string prefix = string.Format(CultureInfo.InvariantCulture, UserMessagePrefix, filename); + + return DeleteFileErrors.TryGetValue(errorCode, out var messages) ? + prefix + messages.UserMessage : + prefix + GenericErrorMessage; + } +} diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index d129949f3680..5e4b3545a218 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -3,26 +3,58 @@ // See the LICENSE file in the project root for more information. using System; -using System.Linq; - +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using ManagedCommon; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Peek.Common.Helpers; using Peek.Common.Models; +using Peek.UI.Helpers; using Peek.UI.Models; using Windows.Win32.Foundation; +using static Peek.UI.Native.NativeMethods; namespace Peek.UI { public partial class MainWindowViewModel : ObservableObject { - private static readonly string _defaultWindowTitle = ResourceLoaderInstance.ResourceLoader.GetString("AppTitle/Title"); + /// + /// The minimum time in milliseconds between navigation events. + /// private const int NavigationThrottleDelayMs = 100; - [ObservableProperty] + /// + /// The delay in milliseconds before a delete operation begins, to allow for navigation + /// away from the current item to occur. + /// + private const int DeleteDelayMs = 200; + + /// + /// Holds the indexes of each the user has deleted. + /// + private readonly HashSet _deletedItemIndexes = []; + + private static readonly string _defaultWindowTitle = ResourceLoaderInstance.ResourceLoader.GetString("AppTitle/Title"); + + /// + /// The actual index of the current item in the items array. Does not necessarily + /// correspond to if one or more files have been deleted. + /// private int _currentIndex; + /// + /// The item index to display in the titlebar. + /// + [ObservableProperty] + private int _displayIndex; + + /// + /// The item to be displayed by a matching previewer. May be null if the user has deleted + /// all items. + /// [ObservableProperty] private IFileSystemItem? _currentItem; @@ -37,11 +69,49 @@ partial void OnCurrentItemChanged(IFileSystemItem? value) private string _windowTitle; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayItemCount))] private NeighboringItems? _items; + /// + /// The number of items selected and available to preview. Decreases as the user deletes + /// items. Displayed on the title bar. + /// + private int _displayItemCount; + + public int DisplayItemCount + { + get => Items?.Count - _deletedItemIndexes.Count ?? 0; + set + { + if (_displayItemCount != value) + { + _displayItemCount = value; + OnPropertyChanged(); + } + } + } + [ObservableProperty] private double _scalingFactor = 1.0; + [ObservableProperty] + private string _errorMessage = string.Empty; + + [ObservableProperty] + private bool _isErrorVisible = false; + + private enum NavigationDirection + { + Forwards, + Backwards, + } + + /// + /// The current direction in which the user is moving through the items collection. + /// Determines how we act when a file is deleted. + /// + private NavigationDirection _navigationDirection = NavigationDirection.Forwards; + public NeighboringItemsQuery NeighboringItemsQuery { get; } private DispatcherTimer NavigationThrottleTimer { get; set; } = new(); @@ -63,50 +133,148 @@ public void Initialize(HWND foregroundWindowHandle) } catch (Exception ex) { - Logger.LogError("Failed to get File Explorer Items: " + ex.Message); + Logger.LogError("Failed to get File Explorer Items.", ex); } - CurrentIndex = 0; + _currentIndex = DisplayIndex = 0; - if (Items != null && Items.Count > 0) - { - CurrentItem = Items[0]; - } + CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null; } public void Uninitialize() { - CurrentIndex = 0; + _currentIndex = DisplayIndex = 0; CurrentItem = null; + _deletedItemIndexes.Clear(); Items = null; + _navigationDirection = NavigationDirection.Forwards; + IsErrorVisible = false; } - public void AttemptPreviousNavigation() + public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards); + + public void AttemptNextNavigation() => Navigate(NavigationDirection.Forwards); + + private void Navigate(NavigationDirection direction, bool isAfterDelete = false) { if (NavigationThrottleTimer.IsEnabled) { return; } - NavigationThrottleTimer.Start(); + if (Items == null || Items.Count == _deletedItemIndexes.Count) + { + _currentIndex = DisplayIndex = 0; + CurrentItem = null; + return; + } + + _navigationDirection = direction; + + int offset = direction == NavigationDirection.Forwards ? 1 : -1; + + do + { + _currentIndex = MathHelper.Modulo(_currentIndex + offset, Items.Count); + } + while (_deletedItemIndexes.Contains(_currentIndex)); - var itemCount = Items?.Count ?? 1; - CurrentIndex = MathHelper.Modulo(CurrentIndex - 1, itemCount); - CurrentItem = Items?.ElementAtOrDefault(CurrentIndex); + CurrentItem = Items[_currentIndex]; + + // If we're navigating forwards after a delete operation, the displayed index does not + // change, e.g. "(2/3)" becomes "(2/2)". + if (isAfterDelete && direction == NavigationDirection.Forwards) + { + offset = 0; + } + + DisplayIndex = MathHelper.Modulo(DisplayIndex + offset, DisplayItemCount); + + NavigationThrottleTimer.Start(); } - public void AttemptNextNavigation() + /// + /// Sends the current item to the Recycle Bin. + /// + public void DeleteItem() { - if (NavigationThrottleTimer.IsEnabled) + if (CurrentItem == null) { return; } - NavigationThrottleTimer.Start(); + var item = CurrentItem; + + if (File.Exists(item.Path) && !IsFilePath(item.Path)) + { + // The path is to a folder, not a file, or its attributes could not be retrieved. + return; + } + + // Update the file count and total files. + int index = _currentIndex; + _deletedItemIndexes.Add(index); + OnPropertyChanged(nameof(DisplayItemCount)); + + // Attempt the deletion then navigate to the next file. + DispatcherQueue.GetForCurrentThread().TryEnqueue(() => + { + Task.Delay(DeleteDelayMs); + int result = DeleteFile(item); + + if (result != 0) + { + // On failure, log the error, show a message in the UI, and reinstate the + // deleted file if it still exists. + DeleteErrorMessageHelper.LogError(result); + ShowDeleteError(item.Name, result); + + if (File.Exists(item.Path)) + { + _deletedItemIndexes.Remove(index); + OnPropertyChanged(nameof(DisplayItemCount)); + } + } + }); + + Navigate(_navigationDirection, isAfterDelete: true); + } + + private int DeleteFile(IFileSystemItem item, bool permanent = false) + { + SHFILEOPSTRUCT fileOp = new() + { + wFunc = FO_DELETE, + pFrom = item.Path + "\0\0", + fFlags = (ushort)(FOF_NO_CONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)), + }; + + return SHFileOperation(ref fileOp); + } + + private static bool IsFilePath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } - var itemCount = Items?.Count ?? 1; - CurrentIndex = MathHelper.Modulo(CurrentIndex + 1, itemCount); - CurrentItem = Items?.ElementAtOrDefault(CurrentIndex); + try + { + FileAttributes attributes = File.GetAttributes(path); + return (attributes & FileAttributes.Directory) != FileAttributes.Directory; + } + catch (Exception) + { + return false; + } + } + + private void ShowDeleteError(string filename, int errorCode) + { + IsErrorVisible = false; + ErrorMessage = DeleteErrorMessageHelper.GetUserErrorMessage(filename, errorCode); + IsErrorVisible = true; } private void NavigationThrottleTimer_Tick(object? sender, object e) diff --git a/src/modules/peek/Peek.UI/Models/NeighboringItems.cs b/src/modules/peek/Peek.UI/Models/NeighboringItems.cs index f6a9a744f37c..b63096889f7d 100644 --- a/src/modules/peek/Peek.UI/Models/NeighboringItems.cs +++ b/src/modules/peek/Peek.UI/Models/NeighboringItems.cs @@ -10,7 +10,7 @@ namespace Peek.UI.Models { - public class NeighboringItems : IReadOnlyList + public partial class NeighboringItems : IReadOnlyList { public IFileSystemItem this[int index] => Items[index] = Items[index] ?? ShellItemArray.GetItemAt(index).ToIFileSystemItem(); @@ -27,14 +27,8 @@ public NeighboringItems(IShellItemArray shellItemArray) Items = new IFileSystemItem[Count]; } - public IEnumerator GetEnumerator() - { - return new NeighboringItemsEnumerator(this); - } + public IEnumerator GetEnumerator() => new NeighboringItemsEnumerator(this); - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } } diff --git a/src/modules/peek/Peek.UI/Native/NativeMethods.cs b/src/modules/peek/Peek.UI/Native/NativeMethods.cs index 95badbae0323..ef489e79ef99 100644 --- a/src/modules/peek/Peek.UI/Native/NativeMethods.cs +++ b/src/modules/peek/Peek.UI/Native/NativeMethods.cs @@ -3,9 +3,9 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; - using Peek.Common.Models; namespace Peek.UI.Native @@ -51,5 +51,53 @@ public enum AssocStr [DllImport("user32.dll", CharSet = CharSet.Unicode)] internal static extern int GetClassName(IntPtr hWnd, StringBuilder buf, int nMaxCount); + + /// + /// Shell File Operations structure. Used for file deletion. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + internal struct SHFILEOPSTRUCT + { + public IntPtr hwnd; + public int wFunc; + public string pFrom; + public string pTo; + public ushort fFlags; + public bool fAnyOperationsAborted; + public IntPtr hNameMappings; + public string lpszProgressTitle; + } + + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + internal static extern int SHFileOperation(ref SHFILEOPSTRUCT fileOp); + + /// + /// File delete operation. + /// + internal const int FO_DELETE = 0x0003; + + /// + /// Send to Recycle Bin flag. + /// + internal const int FOF_ALLOWUNDO = 0x0040; + + /// + /// Do not request user confirmation for file delete flag. + /// + internal const int FOF_NO_CONFIRMATION = 0x0010; + + /// + /// Common error codes when calling SHFileOperation to delete a file. + /// + /// See winerror.h for full list. + public static readonly Dictionary DeleteFileErrors = new() + { + { 2, "The system cannot find the file specified." }, + { 3, "The system cannot find the path specified." }, + { 5, "Access is denied." }, + { 19, "The media is write protected." }, + { 32, "The process cannot access the file because it is being used by another process." }, + { 33, "The process cannot access the file because another process has locked a portion of the file." }, + }; } } diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml index eeb47aaf975d..1d7f7b6fbdca 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -1,4 +1,4 @@ - + + + NumberOfFiles="{x:Bind ViewModel.DisplayItemCount, Mode=OneWay}" /> + + diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index ae94b0cb4462..52e5f597c70d 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -55,6 +55,14 @@ public MainWindow() AppWindow.Closing += AppWindow_Closing; } + private void Content_KeyUp(object sender, KeyRoutedEventArgs e) + { + if (e.Key == Windows.System.VirtualKey.Delete) + { + this.ViewModel.DeleteItem(); + } + } + /// /// Toggling the window visibility and querying files when necessary. /// @@ -125,6 +133,7 @@ private void Initialize(Windows.Win32.Foundation.HWND foregroundWindowHandle) ViewModel.Initialize(foregroundWindowHandle); ViewModel.ScalingFactor = this.GetMonitorScale(); + this.Content.KeyUp += Content_KeyUp; bootTime.Stop(); @@ -138,6 +147,8 @@ private void Uninitialize() ViewModel.Uninitialize(); ViewModel.ScalingFactor = 1; + + this.Content.KeyUp -= Content_KeyUp; } /// diff --git a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml index 2d768dcf36d7..57e073d8a5b4 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml @@ -59,7 +59,7 @@ x:Name="AppTitle_FileName" Grid.Column="1" Style="{StaticResource CaptionTextBlockStyle}" - Text="{x:Bind Item.Name, Mode=OneWay}" + Text="{x:Bind FileName, Mode=OneWay}" TextWrapping="NoWrap" /> diff --git a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml.cs index 17d9724d7999..9ed5c327dbe9 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml.cs @@ -55,7 +55,7 @@ public sealed partial class TitleBar : UserControl nameof(NumberOfFiles), typeof(int), typeof(TitleBar), - new PropertyMetadata(null, null)); + new PropertyMetadata(null, (d, e) => ((TitleBar)d).OnNumberOfFilesPropertyChanged())); [ObservableProperty] private string openWithAppText = ResourceLoaderInstance.ResourceLoader.GetString("LaunchAppButton_OpenWith_Text"); @@ -66,6 +66,9 @@ public sealed partial class TitleBar : UserControl [ObservableProperty] private string? fileCountText; + [ObservableProperty] + private string fileName = string.Empty; + [ObservableProperty] private string defaultAppName = string.Empty; @@ -242,28 +245,40 @@ private void UpdateTitleBarCustomization(MainWindow mainWindow) private void OnFilePropertyChanged() { - if (Item == null) - { - return; - } - UpdateFileCountText(); + UpdateFilename(); UpdateDefaultAppToLaunch(); } + private void UpdateFilename() + { + FileName = Item?.Name ?? string.Empty; + } + private void OnFileIndexPropertyChanged() { UpdateFileCountText(); } + private void OnNumberOfFilesPropertyChanged() + { + UpdateFileCountText(); + } + + /// + /// Respond to a change in the current file being previewed or the number of files available. + /// private void UpdateFileCountText() { - // Update file count - if (NumberOfFiles > 1) + if (NumberOfFiles >= 1) { string fileCountTextFormat = ResourceLoaderInstance.ResourceLoader.GetString("AppTitle_FileCounts_Text"); FileCountText = string.Format(CultureInfo.InvariantCulture, fileCountTextFormat, FileIndex + 1, NumberOfFiles); } + else + { + FileCountText = string.Empty; + } } private void UpdateDefaultAppToLaunch() diff --git a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw index c6b7945d3377..f16000d48072 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -326,4 +326,32 @@ Toggle text wrapping Toggle whether text in pane is word-wrapped + + No more files to preview. + The message to show when there are no files remaining to preview. + + + The file cannot be found. Please check if the file has been moved, renamed, or deleted. + Displayed if the file or path was not found + + + Access is denied. Please ensure you have permission to delete the file. + Displayed if access to the file was denied when trying to delete it + + + An error occurred while deleting the file. Please try again later. + Displayed if the file could not be deleted and no other error code matched + + + The file is currently in use by another program. Please close any programs that might be using the file, then try again. + Displayed if the file could not be deleted because it is fully or partially locked by another process + + + The storage medium is write-protected. If possible, remove the write protection then try again. + Displayed if the file could not be deleted because it exists on non-writable media + + + Cannot delete '{0}'. + The prefix added to all file delete failure messages. {0} is replaced with the name of the file + \ No newline at end of file