From 8859273ccecb9765226e37e7fb2d5c4aade313e1 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sun, 13 Oct 2024 18:25:03 +0100 Subject: [PATCH 1/8] Add Delete functionality for Peek. --- Directory.Packages.props | 4 +- .../peek/Peek.FilePreviewer/FilePreview.xaml | 9 + .../Peek.FilePreviewer/FilePreview.xaml.cs | 8 + .../peek/Peek.UI/MainWindowViewModel.cs | 181 +++++++++++++++--- .../peek/Peek.UI/Models/NeighboringItems.cs | 12 +- .../peek/Peek.UI/Native/NativeMethods.cs | 50 ++++- .../peek/Peek.UI/PeekXAML/MainWindow.xaml | 5 +- .../peek/Peek.UI/PeekXAML/MainWindow.xaml.cs | 11 ++ .../peek/Peek.UI/PeekXAML/Views/TitleBar.xaml | 2 +- .../Peek.UI/PeekXAML/Views/TitleBar.xaml.cs | 31 ++- .../peek/Peek.UI/Strings/en-us/Resources.resw | 4 + 11 files changed, 272 insertions(+), 45 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0967532dc32..720c5198937 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -76,12 +76,12 @@ - + - + diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 9943a6962d8..887e8d38364 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -102,6 +102,15 @@ 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))] @@ -62,6 +65,9 @@ public sealed partial class FilePreview : UserControl, IDisposable [ObservableProperty] private string infoTooltip = ResourceLoaderInstance.ResourceLoader.GetString("PreviewTooltip_Blank"); + [ObservableProperty] + private string noMoreFilesText = ResourceLoaderInstance.ResourceLoader.GetString("NoMoreFiles"); + private CancellationTokenSource _cancellationTokenSource = new(); public FilePreview() @@ -158,6 +164,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/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index d129949f368..87755c68de0 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -3,26 +3,57 @@ // 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.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 +68,43 @@ 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; + 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 +126,124 @@ 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; } - 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)); + + 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); - var itemCount = Items?.Count ?? 1; - CurrentIndex = MathHelper.Modulo(CurrentIndex - 1, itemCount); - CurrentItem = Items?.ElementAtOrDefault(CurrentIndex); + NavigationThrottleTimer.Start(); } - public void AttemptNextNavigation() + /// + /// Sends the current item to the Recycle Bin. + /// + public void DeleteItem() { - if (NavigationThrottleTimer.IsEnabled) + if (CurrentItem == null || !IsFilePath(CurrentItem.Path)) { return; } - NavigationThrottleTimer.Start(); + _deletedItemIndexes.Add(_currentIndex); + OnPropertyChanged(nameof(DisplayItemCount)); + + string path = CurrentItem.Path; + + DispatcherQueue.GetForCurrentThread().TryEnqueue(() => + { + Task.Delay(DeleteDelayMs); + DeleteFile(path); + }); - var itemCount = Items?.Count ?? 1; - CurrentIndex = MathHelper.Modulo(CurrentIndex + 1, itemCount); - CurrentItem = Items?.ElementAtOrDefault(CurrentIndex); + Navigate(_navigationDirection, isAfterDelete: true); + } + + private void DeleteFile(string path, bool permanent = false) + { + SHFILEOPSTRUCT fileOp = new() + { + wFunc = FO_DELETE, + pFrom = path + "\0\0", + fFlags = (ushort)(FOF_NOCONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)), + }; + + int result = SHFileOperation(ref fileOp); + + if (result != 0) + { + string warning = "Could not delete file. " + + (DeleteFileErrors.TryGetValue(result, out string? errorMessage) ? errorMessage : $"Error code {result}."); + Logger.LogWarning(warning); + } + } + + private static bool IsFilePath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + try + { + FileAttributes attributes = File.GetAttributes(path); + return (attributes & FileAttributes.Directory) != FileAttributes.Directory; + } + catch (Exception) + { + return false; + } } 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 f6a9a744f37..b63096889f7 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 95badbae032..96acbef1718 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_NOCONFIRMATION = 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 eeb47aaf975..a8d9d98a47f 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -39,14 +39,15 @@ + 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 ae94b0cb446..52e5f597c70 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 2d768dcf36d..57e073d8a5b 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 17d9724d799..9ed5c327dbe 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 c6b7945d337..9c57f263b1e 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,8 @@ 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. + \ No newline at end of file From 3a10918c9f91de64785722e4bdb33c58d1c2daea Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sun, 13 Oct 2024 22:32:38 +0100 Subject: [PATCH 2/8] Delete Directory.Packages.props Removed package updates, as these are not relevant to the new functionality. --- Directory.Packages.props | 96 ---------------------------------------- 1 file changed, 96 deletions(-) delete mode 100644 Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props deleted file mode 100644 index 720c5198937..00000000000 --- a/Directory.Packages.props +++ /dev/null @@ -1,96 +0,0 @@ - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 1c89fc6494185332351876d731f7be6d6d3b8992 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sun, 13 Oct 2024 23:05:22 +0100 Subject: [PATCH 3/8] Updated the "No More Files" text block to use a Uid to load its resource text. Also altered the text style to be consistent with the FailedFallbackPreviewControl error page. --- src/modules/peek/Peek.FilePreviewer/FilePreview.xaml | 5 +++-- src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs | 3 --- src/modules/peek/Peek.UI/Strings/en-us/Resources.resw | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 887e8d38364..707728406d3 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -104,12 +104,13 @@ Visibility="{x:Bind IsUnsupportedPreviewVisible(UnsupportedFilePreviewer, Previewer.State), Mode=OneWay}" /> diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs index c60d062d707..690dfdbade8 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs @@ -65,9 +65,6 @@ public sealed partial class FilePreview : UserControl, IDisposable [ObservableProperty] private string infoTooltip = ResourceLoaderInstance.ResourceLoader.GetString("PreviewTooltip_Blank"); - [ObservableProperty] - private string noMoreFilesText = ResourceLoaderInstance.ResourceLoader.GetString("NoMoreFiles"); - private CancellationTokenSource _cancellationTokenSource = new(); public FilePreview() 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 9c57f263b1e..ea8d8d87b3e 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -326,7 +326,7 @@ 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. From 0cba3011c0234e4f0682c8111b9a18a9c24466ec Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Mon, 14 Oct 2024 19:24:24 +0100 Subject: [PATCH 4/8] Revert "Delete Directory.Packages.props" This reverts commit 3a10918c9f91de64785722e4bdb33c58d1c2daea. --- Directory.Packages.props | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 Directory.Packages.props diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000000..720c5198937 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,96 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 2eb0a99e3eb3f75fa47862b760ab13b051d7017c Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Mon, 14 Oct 2024 22:35:18 +0100 Subject: [PATCH 5/8] Attempt to appease the spell-checking bot by renaming flag const. --- src/modules/peek/Peek.UI/MainWindowViewModel.cs | 2 +- src/modules/peek/Peek.UI/Native/NativeMethods.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index 87755c68de0..7d4f2f93a33 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -215,7 +215,7 @@ private void DeleteFile(string path, bool permanent = false) { wFunc = FO_DELETE, pFrom = path + "\0\0", - fFlags = (ushort)(FOF_NOCONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)), + fFlags = (ushort)(FOF_NO_CONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)), }; int result = SHFileOperation(ref fileOp); diff --git a/src/modules/peek/Peek.UI/Native/NativeMethods.cs b/src/modules/peek/Peek.UI/Native/NativeMethods.cs index 96acbef1718..ef489e79ef9 100644 --- a/src/modules/peek/Peek.UI/Native/NativeMethods.cs +++ b/src/modules/peek/Peek.UI/Native/NativeMethods.cs @@ -84,7 +84,7 @@ internal struct SHFILEOPSTRUCT /// /// Do not request user confirmation for file delete flag. /// - internal const int FOF_NOCONFIRMATION = 0x0010; + internal const int FOF_NO_CONFIRMATION = 0x0010; /// /// Common error codes when calling SHFileOperation to delete a file. From ee8391572f86f8367a8a92f14cfd25ff5f838433 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sat, 19 Oct 2024 21:24:34 +0100 Subject: [PATCH 6/8] Show error message InfoBar if file deletion failed. --- .../Helpers/DeleteErrorMessageHelper.cs | 100 ++++++++++++++++++ .../peek/Peek.UI/MainWindowViewModel.cs | 61 ++++++++--- .../peek/Peek.UI/PeekXAML/MainWindow.xaml | 12 +++ .../peek/Peek.UI/Strings/en-us/Resources.resw | 24 +++++ 4 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs 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 00000000000..8c062a80ce6 --- /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 7d4f2f93a33..5e4b3545a21 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -12,6 +12,7 @@ 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; @@ -93,6 +94,12 @@ public int DisplayItemCount [ObservableProperty] private double _scalingFactor = 1.0; + [ObservableProperty] + private string _errorMessage = string.Empty; + + [ObservableProperty] + private bool _isErrorVisible = false; + private enum NavigationDirection { Forwards, @@ -141,6 +148,7 @@ public void Uninitialize() _deletedItemIndexes.Clear(); Items = null; _navigationDirection = NavigationDirection.Forwards; + IsErrorVisible = false; } public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards); @@ -190,42 +198,58 @@ private void Navigate(NavigationDirection direction, bool isAfterDelete = false) /// public void DeleteItem() { - if (CurrentItem == null || !IsFilePath(CurrentItem.Path)) + if (CurrentItem == null) { return; } - _deletedItemIndexes.Add(_currentIndex); - OnPropertyChanged(nameof(DisplayItemCount)); + var item = CurrentItem; - string path = CurrentItem.Path; + 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); - DeleteFile(path); + 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 void DeleteFile(string path, bool permanent = false) + private int DeleteFile(IFileSystemItem item, bool permanent = false) { SHFILEOPSTRUCT fileOp = new() { wFunc = FO_DELETE, - pFrom = path + "\0\0", + pFrom = item.Path + "\0\0", fFlags = (ushort)(FOF_NO_CONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)), }; - int result = SHFileOperation(ref fileOp); - - if (result != 0) - { - string warning = "Could not delete file. " + - (DeleteFileErrors.TryGetValue(result, out string? errorMessage) ? errorMessage : $"Error code {result}."); - Logger.LogWarning(warning); - } + return SHFileOperation(ref fileOp); } private static bool IsFilePath(string path) @@ -246,6 +270,13 @@ private static bool IsFilePath(string path) } } + private void ShowDeleteError(string filename, int errorCode) + { + IsErrorVisible = false; + ErrorMessage = DeleteErrorMessageHelper.GetUserErrorMessage(filename, errorCode); + IsErrorVisible = true; + } + private void NavigationThrottleTimer_Tick(object? sender, object e) { if (sender == null) diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml index a8d9d98a47f..7b692526d83 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -34,6 +34,7 @@ + + + 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 ea8d8d87b3e..f16000d4807 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -330,4 +330,28 @@ 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 From c4ac99be245b6f09d10f0f83dbbbf311785183b1 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sat, 19 Oct 2024 21:57:02 +0100 Subject: [PATCH 7/8] Resolve XAML styling. --- src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml index 7b692526d83..1d7f7b6fbdc 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -1,4 +1,4 @@ - + From 49c7a5e28a7e225f4281e3b98b189b9d679f8fe2 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sat, 19 Oct 2024 22:19:07 +0100 Subject: [PATCH 8/8] XAML styling fix. --- src/modules/peek/Peek.FilePreviewer/FilePreview.xaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 707728406d3..c3bbe50c61f 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -104,14 +104,14 @@ Visibility="{x:Bind IsUnsupportedPreviewVisible(UnsupportedFilePreviewer, Previewer.State), Mode=OneWay}" /> + Visibility="Collapsed" />