From 8a919f51723045b485b66a5b6a904501ba4ed7ba Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sun, 13 Oct 2024 18:25:03 +0100 Subject: [PATCH 01/14] Add Delete functionality for Peek. --- .../peek/Peek.FilePreviewer/FilePreview.xaml | 9 + .../Peek.FilePreviewer/FilePreview.xaml.cs | 8 + .../peek/Peek.UI/MainWindowViewModel.cs | 181 +++++++++++++++--- .../peek/Peek.UI/Models/NeighboringItems.cs | 10 +- .../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 + 10 files changed, 269 insertions(+), 42 deletions(-) diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 9943a6962d87..887e8d383642 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 d129949f3680..87755c68de0b 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 c528cc2b7ebc..b63096889f7d 100644 --- a/src/modules/peek/Peek.UI/Models/NeighboringItems.cs +++ b/src/modules/peek/Peek.UI/Models/NeighboringItems.cs @@ -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..96acbef1718c 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 eeb47aaf975d..a8d9d98a47f6 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 7295dd1dcca6..64219f385302 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -56,6 +56,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. /// @@ -127,6 +135,7 @@ private void Initialize(Windows.Win32.Foundation.HWND foregroundWindowHandle) ViewModel.Initialize(foregroundWindowHandle); ViewModel.ScalingFactor = this.GetMonitorScale(); + this.Content.KeyUp += Content_KeyUp; bootTime.Stop(); @@ -140,6 +149,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 7e2759cc5dbc..9b2fd98ddaab 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 97b81db0b445..6bfe268742be 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -329,4 +329,8 @@ Toggle minimap + + No more files to preview. + The message to show when there are no files remaining to preview. + \ No newline at end of file From 23d600f032227b510e8ba12b0c7ed7e9122be394 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sun, 13 Oct 2024 23:05:22 +0100 Subject: [PATCH 02/14] 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 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 887e8d383642..707728406d38 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}" /> From a5afc8eb6f4d63d326c0c6a5df6463d6a7d77560 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Mon, 14 Oct 2024 19:24:24 +0100 Subject: [PATCH 03/14] Revert "Delete Directory.Packages.props" This reverts commit 3a10918c9f91de64785722e4bdb33c58d1c2daea. --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index c04a97ec804f..4213ec9d0bb2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,4 +98,4 @@ - + \ No newline at end of file From f575e6d0e1bde5771ac0ea1d5b924e65285f713b Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Mon, 14 Oct 2024 22:35:18 +0100 Subject: [PATCH 04/14] 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 87755c68de0b..7d4f2f93a33c 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 96acbef1718c..ef489e79ef99 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 92a22ff46cac833734cd2f1921f3218c3c745b23 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sat, 19 Oct 2024 21:24:34 +0100 Subject: [PATCH 05/14] 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 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 7d4f2f93a33c..5e4b3545a218 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 a8d9d98a47f6..7b692526d83d 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 6bfe268742be..2757229c51c2 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -333,4 +333,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 43c9ae82047e71b97c0ee91272443457ff4749a0 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sat, 19 Oct 2024 21:57:02 +0100 Subject: [PATCH 06/14] 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 7b692526d83d..1d7f7b6fbdca 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -1,4 +1,4 @@ - + From dc285e7c82ee2fadb73a82744c5efb3c5fe314bf Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Sat, 19 Oct 2024 22:19:07 +0100 Subject: [PATCH 07/14] 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 707728406d38..c3bbe50c61fd 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" /> Date: Wed, 22 Jan 2025 17:21:04 +0000 Subject: [PATCH 08/14] Settings app updates for new delete confirmation setting. --- .../Settings.UI.Library/PeekProperties.cs | 3 +++ .../Settings.UI/SettingsXAML/Views/PeekPage.xaml | 3 +++ .../Settings.UI/Strings/en-us/Resources.resw | 6 ++++++ .../Settings.UI/ViewModels/PeekViewModel.cs | 14 ++++++++++++++ 4 files changed, 26 insertions(+) diff --git a/src/settings-ui/Settings.UI.Library/PeekProperties.cs b/src/settings-ui/Settings.UI.Library/PeekProperties.cs index 1e4514d86631..f81a3bc9a65e 100644 --- a/src/settings-ui/Settings.UI.Library/PeekProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PeekProperties.cs @@ -18,6 +18,7 @@ public PeekProperties() ActivationShortcut = DefaultActivationShortcut; AlwaysRunNotElevated = new BoolProperty(true); CloseAfterLosingFocus = new BoolProperty(false); + ConfirmFileDelete = new BoolProperty(true); } public HotkeySettings ActivationShortcut { get; set; } @@ -26,6 +27,8 @@ public PeekProperties() public BoolProperty CloseAfterLosingFocus { get; set; } + public BoolProperty ConfirmFileDelete { get; set; } + public override string ToString() => JsonSerializer.Serialize(this); } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml index a0339f35cba4..1bf46dec07c2 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml @@ -42,6 +42,9 @@ + + + diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index 58aa6816b43d..f0ac11ff684f 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -3024,6 +3024,12 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Automatically close the Peek window after it loses focus Peek is a product name, do not loc + + Ask for confirmation before deleting files + + + When enabled, you will be prompted to confirm before moving files to the Recycle Bin. + Disable round corners when window is snapped diff --git a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs index f6f6eac63be2..0d46aa127bc3 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs @@ -146,6 +146,20 @@ public bool CloseAfterLosingFocus } } + public bool ConfirmFileDelete + { + get => _peekSettings.Properties.ConfirmFileDelete.Value; + set + { + if (_peekSettings.Properties.ConfirmFileDelete.Value != value) + { + _peekSettings.Properties.ConfirmFileDelete.Value = value; + OnPropertyChanged(nameof(ConfirmFileDelete)); + NotifySettingsChanged(); + } + } + } + public bool SourceCodeWrapText { get => _peekPreviewSettings.SourceCodeWrapText.Value; From 36882f46bf076ff537a609b4791dd758583b6d28 Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Wed, 22 Jan 2025 17:42:48 +0000 Subject: [PATCH 09/14] Add delete confirmation dialog and settings to Peek. Add shell notification event after delete operation. --- .../peek/Peek.UI/MainWindowViewModel.cs | 70 +++++++++++++- .../peek/Peek.UI/Native/NativeMethods.cs | 41 +++++++-- .../peek/Peek.UI/PeekXAML/MainWindow.xaml | 12 ++- .../peek/Peek.UI/PeekXAML/MainWindow.xaml.cs | 92 ++++++++++++++++++- .../peek/Peek.UI/Services/IUserSettings.cs | 2 + .../peek/Peek.UI/Services/UserSettings.cs | 63 +++++++++---- .../peek/Peek.UI/Strings/en-us/Resources.resw | 15 +++ 7 files changed, 263 insertions(+), 32 deletions(-) diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index 5e4b3545a218..16279786ea62 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -4,12 +4,15 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using ManagedCommon; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; +using Peek.Common.Extensions; using Peek.Common.Helpers; using Peek.Common.Models; using Peek.UI.Helpers; @@ -63,6 +66,9 @@ partial void OnCurrentItemChanged(IFileSystemItem? value) WindowTitle = value != null ? ReadableStringHelper.FormatResourceString("WindowTitle", value.Name) : _defaultWindowTitle; + + DeleteConfirmationDialogMessage = + ReadableStringHelper.FormatResourceString("DeleteConfirmationDialog_Message", value?.Name ?? string.Empty); } [ObservableProperty] @@ -72,6 +78,9 @@ partial void OnCurrentItemChanged(IFileSystemItem? value) [NotifyPropertyChangedFor(nameof(DisplayItemCount))] private NeighboringItems? _items; + [ObservableProperty] + private string _deleteConfirmationDialogMessage = string.Empty; + /// /// The number of items selected and available to preview. Decreases as the user deletes /// items. Displayed on the title bar. @@ -196,13 +205,19 @@ private void Navigate(NavigationDirection direction, bool isAfterDelete = false) /// /// Sends the current item to the Recycle Bin. /// - public void DeleteItem() + /// The IsChecked property of the "Don't ask me + /// again" checkbox on the delete confirmation dialog. + public void DeleteItem(bool? skipConfirmationChecked) { if (CurrentItem == null) { return; } + bool skipConfirmation = skipConfirmationChecked ?? false; + bool shouldShowConfirmation = !skipConfirmation; + Application.Current.GetService().ConfirmFileDelete = shouldShowConfirmation; + var item = CurrentItem; if (File.Exists(item.Path) && !IsFilePath(item.Path)) @@ -240,16 +255,63 @@ public void DeleteItem() Navigate(_navigationDirection, isAfterDelete: true); } + /// + /// Delete a file and refresh any shell listeners. + /// + /// The item to delete. + /// Set to false (the default) to send the file to the Recycle Bin, + /// if possible. Set to true to permanently delete the item. + /// The result of the file operation call. A non-zero result indicates failure. + /// private int DeleteFile(IFileSystemItem item, bool permanent = false) { + // We handle delete confirmations with our own dialog, so it is not required here. + ushort flags = FOF_NO_CONFIRMATION; + + if (!permanent) + { + // Move to the Recycle Bin and warn about permanent deletes. + flags |= (ushort)(FOF_ALLOWUNDO | FOF_WANT_NUKE_WARNING); + } + SHFILEOPSTRUCT fileOp = new() { wFunc = FO_DELETE, - pFrom = item.Path + "\0\0", - fFlags = (ushort)(FOF_NO_CONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)), + pFrom = item.Path + "\0\0", // Path arguments must be double null-terminated. + fFlags = flags, }; - return SHFileOperation(ref fileOp); + int result = SHFileOperation(ref fileOp); + if (result == 0) + { + SendDeleteChangeNotification(item.Path); + } + + return result; + } + + /// + /// Informs shell listeners like Explorer windows that a delete operation has occurred. + /// + /// Full path to the file which was deleted. + private void SendDeleteChangeNotification(string path) + { + IntPtr pathPtr = Marshal.StringToHGlobalUni(path); + try + { + if (pathPtr == IntPtr.Zero) + { + Logger.LogError("Could not allocate memory for path string."); + } + else + { + SHChangeNotify(SHCNE_DELETE, SHCNF_PATH, pathPtr, IntPtr.Zero); + } + } + finally + { + Marshal.FreeHGlobal(pathPtr); + } } private static bool IsFilePath(string path) diff --git a/src/modules/peek/Peek.UI/Native/NativeMethods.cs b/src/modules/peek/Peek.UI/Native/NativeMethods.cs index ef489e79ef99..2c353a12c58d 100644 --- a/src/modules/peek/Peek.UI/Native/NativeMethods.cs +++ b/src/modules/peek/Peek.UI/Native/NativeMethods.cs @@ -55,11 +55,11 @@ public enum AssocStr /// /// Shell File Operations structure. Used for file deletion. /// - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal struct SHFILEOPSTRUCT { public IntPtr hwnd; - public int wFunc; + public uint wFunc; public string pFrom; public string pTo; public ushort fFlags; @@ -68,23 +68,31 @@ internal struct SHFILEOPSTRUCT public string lpszProgressTitle; } - [DllImport("shell32.dll", CharSet = CharSet.Auto)] + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] internal static extern int SHFileOperation(ref SHFILEOPSTRUCT fileOp); /// /// File delete operation. /// - internal const int FO_DELETE = 0x0003; + internal const uint FO_DELETE = 0x0003; /// /// Send to Recycle Bin flag. /// - internal const int FOF_ALLOWUNDO = 0x0040; + internal const ushort FOF_ALLOWUNDO = 0x0040; + + /// + /// Do not request user confirmation for file deletes. + /// + internal const ushort FOF_NO_CONFIRMATION = 0x0010; /// - /// Do not request user confirmation for file delete flag. + /// Warn if a file cannot be recycled and would instead be permanently deleted. (Partially + /// overrides FOF_NO_CONFIRMATION.) This can be tested by attempting to delete a file on a + /// FAT volume, e.g. a USB key. /// - internal const int FOF_NO_CONFIRMATION = 0x0010; + /// FOF_WANTNUKEWARNING in shellapi.h./remarks> + internal const ushort FOF_WANT_NUKE_WARNING = 0x4000; /// /// Common error codes when calling SHFileOperation to delete a file. @@ -99,5 +107,24 @@ internal struct SHFILEOPSTRUCT { 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." }, }; + + /// + /// Shell Change Notify. Used to inform shell listeners after we've completed a file + /// operation like Delete or Move. + /// + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + internal static extern void SHChangeNotify(uint wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + + /// + /// File System Notification Flag, indicating that the operation was a file deletion. + /// + /// See ShlObj_core.h for constant definitions. + internal const uint SHCNE_DELETE = 0x00000004; + + /// + /// Indicates that SHChangeNotify's dwItem1 and (optionally) dwItem2 parameters will + /// contain string paths. + /// + internal const uint SHCNF_PATH = 0x0001; } } diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml index 1d7f7b6fbdca..101565e01729 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -18,7 +18,7 @@ - + @@ -62,5 +62,15 @@ IsOpen="{x:Bind ViewModel.IsErrorVisible, Mode=TwoWay}" Message="{x:Bind ViewModel.ErrorMessage, Mode=OneWay}" Severity="Error" /> + + + + + + + diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index 64219f385302..aa8295409d41 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -3,12 +3,13 @@ // See the LICENSE file in the project root for more information. using System; - +using System.Threading.Tasks; using ManagedCommon; using Microsoft.PowerToys.Telemetry; using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; using Peek.Common.Constants; using Peek.Common.Extensions; @@ -30,6 +31,18 @@ public sealed partial class MainWindow : WindowEx, IDisposable private readonly ThemeListener? themeListener; + // Used to work around a focus visual issue when the delete confirmation dialog is + // triggered by a key event. + private readonly Thickness _zeroThickness = new(0); + private Thickness _defaultFocusPrimaryThickness = new(2); + private Thickness _defaultFocusSecondaryThickness = new(2); + + /// + /// Whether the delete confirmation dialog is currently open. Used to ensure only one + /// dialog is open at a time. + /// + private bool _isDeleteInProgress; + public MainWindow() { InitializeComponent(); @@ -56,12 +69,85 @@ public MainWindow() AppWindow.Closing += AppWindow_Closing; } - private void Content_KeyUp(object sender, KeyRoutedEventArgs e) + private async void Content_KeyUp(object sender, KeyRoutedEventArgs e) { if (e.Key == Windows.System.VirtualKey.Delete) { - this.ViewModel.DeleteItem(); + await DeleteItem(); } + + // Restore the default focus visual. The Space key is excluded as that is used to toggle + // the checkbox when it has focus. + if (e.Key != Windows.System.VirtualKey.Space) + { + RestoreFocusThickness(); + } + } + + private async Task DeleteItem() + { + if (ViewModel.CurrentItem == null || _isDeleteInProgress) + { + return; + } + + try + { + _isDeleteInProgress = true; + + if (Application.Current.GetService().ConfirmFileDelete) + { + if (await ShowDeleteConfirmationDialogAsync() == ContentDialogResult.Primary) + { + // Delete after asking for confirmation. Persist the "Don't ask again" choice if set. + ViewModel.DeleteItem(DeleteDontAskCheckbox.IsChecked); + } + } + else + { + // Delete without confirmation. + ViewModel.DeleteItem(true); + } + } + finally + { + _isDeleteInProgress = false; + } + } + + private async Task ShowDeleteConfirmationDialogAsync() + { + DeleteDontAskCheckbox.IsChecked = false; + + CacheFocusThickness(); + + // Hide the default focus visual. This prevents its initial display when the dialog is + // opened via a keyboard event. + DeleteDontAskCheckbox.FocusVisualPrimaryThickness = _zeroThickness; + DeleteDontAskCheckbox.FocusVisualSecondaryThickness = _zeroThickness; + + DeleteConfirmationDialog.XamlRoot = Content.XamlRoot; + + return await DeleteConfirmationDialog.ShowAsync(); + } + + /// + /// Save the current focus visual thickness. This will be restored when the user interacts + /// with the dialog, e.g. by using Tab. + /// + private void CacheFocusThickness() + { + CheckBox hiddenCheckBox = new() { Visibility = Visibility.Collapsed }; + MainGrid.Children.Add(hiddenCheckBox); + _defaultFocusPrimaryThickness = hiddenCheckBox.FocusVisualPrimaryThickness; + _defaultFocusSecondaryThickness = hiddenCheckBox.FocusVisualSecondaryThickness; + MainGrid.Children.Remove(hiddenCheckBox); + } + + private void RestoreFocusThickness() + { + DeleteDontAskCheckbox.FocusVisualPrimaryThickness = _defaultFocusPrimaryThickness; + DeleteDontAskCheckbox.FocusVisualSecondaryThickness = _defaultFocusSecondaryThickness; } /// diff --git a/src/modules/peek/Peek.UI/Services/IUserSettings.cs b/src/modules/peek/Peek.UI/Services/IUserSettings.cs index bde6f8517314..65d5e67f18bf 100644 --- a/src/modules/peek/Peek.UI/Services/IUserSettings.cs +++ b/src/modules/peek/Peek.UI/Services/IUserSettings.cs @@ -7,5 +7,7 @@ namespace Peek.UI public interface IUserSettings { public bool CloseAfterLosingFocus { get; } + + public bool ConfirmFileDelete { get; set; } } } diff --git a/src/modules/peek/Peek.UI/Services/UserSettings.cs b/src/modules/peek/Peek.UI/Services/UserSettings.cs index e4a73353c1ab..64a6f8f2f6dc 100644 --- a/src/modules/peek/Peek.UI/Services/UserSettings.cs +++ b/src/modules/peek/Peek.UI/Services/UserSettings.cs @@ -24,30 +24,60 @@ public class UserSettings : IUserSettings private readonly IFileSystemWatcher _watcher; /// - /// Gets a value indicating whether Peek closes automatically when the window loses focus. + /// The current settings. Initially set to defaults. /// - public bool CloseAfterLosingFocus { get; private set; } + private PeekSettings _settings = new(); - public UserSettings() + private PeekSettings Settings { - _settingsUtils = new SettingsUtils(); + get => _settings; + set + { + lock (_settingsLock) + { + _settings = value; + CloseAfterLosingFocus = _settings.Properties.CloseAfterLosingFocus.Value; + ConfirmFileDelete = _settings.Properties.ConfirmFileDelete.Value; + } + } + } - LoadSettingsFromJson(); + /// + /// Gets a value indicating whether Peek closes automatically when the window loses focus. + /// + public bool CloseAfterLosingFocus { get; private set; } - _watcher = Helper.GetFileWatcher(PeekModuleName, SettingsUtils.DefaultFileName, LoadSettingsFromJson); - } + private bool _confirmFileDelete; - private void ApplySettings(PeekSettings settings) + /// + /// Gets or sets a value indicating whether the user is prompted before a file is recycled. + /// + /// The user will always be prompted when the file cannot be sent to the Recycle + /// Bin and would instead be permanently deleted. + public bool ConfirmFileDelete { - lock (_settingsLock) + get => _confirmFileDelete; + set { - CloseAfterLosingFocus = settings.Properties.CloseAfterLosingFocus.Value; + if (_confirmFileDelete != value) + { + _confirmFileDelete = value; + lock (_settingsLock) + { + _settings.Properties.ConfirmFileDelete.Value = _confirmFileDelete; + _settingsUtils.SaveSettings(_settings.ToJsonString(), PeekModuleName); + } + } } } - private void ApplyDefaultSettings() + public UserSettings() { - ApplySettings(new PeekSettings()); + _settingsUtils = new SettingsUtils(); + + LoadSettingsFromJson(); + + _watcher = Helper.GetFileWatcher(PeekModuleName, SettingsUtils.DefaultFileName, LoadSettingsFromJson); } private void LoadSettingsFromJson() @@ -56,7 +86,7 @@ private void LoadSettingsFromJson() { try { - ApplySettings(_settingsUtils.GetSettingsOrDefault(PeekModuleName)); + Settings = _settingsUtils.GetSettingsOrDefault(PeekModuleName); return; } catch (System.IO.IOException ex) @@ -65,8 +95,7 @@ private void LoadSettingsFromJson() if (attempt == MaxAttempts) { Logger.LogError($"Failed to load Peek settings after {MaxAttempts} attempts. Continuing with default settings."); - ApplyDefaultSettings(); - return; + break; } // Exponential back-off then retry. @@ -76,10 +105,10 @@ private void LoadSettingsFromJson() { // Anything other than an IO exception is an immediate failure. Logger.LogError($"Peek settings load failed, continuing with defaults: {ex.Message}", ex); - ApplyDefaultSettings(); - return; } } + + Settings = new PeekSettings(); } private static int CalculateRetryDelay(int attempt) 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 2757229c51c2..11b2b6cd0b03 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -357,4 +357,19 @@ Cannot delete '{0}'. The prefix added to all file delete failure messages. {0} is replaced with the name of the file + + Delete file + + + Move to Recycle Bin + + + Cancel + + + Are you sure you want to move "{0}" to the Recycle Bin? + + + Don't ask me again + \ No newline at end of file From a3c0fa4c5bfd520b5a8f9588b938d91e43ad600f Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Mon, 27 Jan 2025 04:40:34 +0000 Subject: [PATCH 10/14] Spelling updates. --- .github/actions/spell-check/expect.txt | 3 +++ src/modules/peek/Peek.UI/MainWindowViewModel.cs | 2 +- src/modules/peek/Peek.UI/Native/NativeMethods.cs | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 325b984c1cd3..26991d087944 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -490,6 +490,7 @@ flac flyouts FMask FOF +FOF_WANTNUKEWARNING FOFX FOLDERID folderpath @@ -1414,6 +1415,7 @@ SHCNE SHCNF SHCONTF Shcore +shellapi SHELLDETAILS SHELLDLL shellex @@ -1806,6 +1808,7 @@ windowssearch windowssettings WINDOWSTYLES WINDOWSTYLESICON +winerror WINEVENT winget wingetcreate diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index 16279786ea62..66bd852c2ad4 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -271,7 +271,7 @@ private int DeleteFile(IFileSystemItem item, bool permanent = false) if (!permanent) { // Move to the Recycle Bin and warn about permanent deletes. - flags |= (ushort)(FOF_ALLOWUNDO | FOF_WANT_NUKE_WARNING); + flags |= (ushort)(FOF_ALLOWUNDO | FOF_WANTNUKEWARNING); } SHFILEOPSTRUCT fileOp = new() diff --git a/src/modules/peek/Peek.UI/Native/NativeMethods.cs b/src/modules/peek/Peek.UI/Native/NativeMethods.cs index 2c353a12c58d..1fe0035f3e54 100644 --- a/src/modules/peek/Peek.UI/Native/NativeMethods.cs +++ b/src/modules/peek/Peek.UI/Native/NativeMethods.cs @@ -91,8 +91,8 @@ internal struct SHFILEOPSTRUCT /// overrides FOF_NO_CONFIRMATION.) This can be tested by attempting to delete a file on a /// FAT volume, e.g. a USB key. /// - /// FOF_WANTNUKEWARNING in shellapi.h./remarks> - internal const ushort FOF_WANT_NUKE_WARNING = 0x4000; + /// Declared in shellapi.h./remarks> + internal const ushort FOF_WANTNUKEWARNING = 0x4000; /// /// Common error codes when calling SHFileOperation to delete a file. From c94f1e7aa6a152ad9c528d2919d89dad10c1acdd Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Mon, 27 Jan 2025 04:52:57 +0000 Subject: [PATCH 11/14] Spelling update. --- .github/actions/spell-check/expect.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 26991d087944..0f940c09674b 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -490,7 +490,7 @@ flac flyouts FMask FOF -FOF_WANTNUKEWARNING +WANTNUKEWARNING FOFX FOLDERID folderpath From 614c6625c1cb63b22a0acd0029869ddf953b7b7b Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Tue, 28 Jan 2025 14:21:07 +0000 Subject: [PATCH 12/14] Remove permanent delete parameter, YAGNI. Add hwnd parameter to delete so warning dialogs are correctly parented. Fix flags to not hide permanent delete warning. --- .../peek/Peek.UI/MainWindowViewModel.cs | 22 +++++++------------ .../peek/Peek.UI/PeekXAML/MainWindow.xaml.cs | 4 ++-- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index 66bd852c2ad4..689ca34eadb7 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -207,7 +207,7 @@ private void Navigate(NavigationDirection direction, bool isAfterDelete = false) /// /// The IsChecked property of the "Don't ask me /// again" checkbox on the delete confirmation dialog. - public void DeleteItem(bool? skipConfirmationChecked) + public void DeleteItem(bool? skipConfirmationChecked, nint hwnd) { if (CurrentItem == null) { @@ -235,7 +235,7 @@ public void DeleteItem(bool? skipConfirmationChecked) DispatcherQueue.GetForCurrentThread().TryEnqueue(() => { Task.Delay(DeleteDelayMs); - int result = DeleteFile(item); + int result = DeleteFile(item, hwnd); if (result != 0) { @@ -256,29 +256,23 @@ public void DeleteItem(bool? skipConfirmationChecked) } /// - /// Delete a file and refresh any shell listeners. + /// Delete a file by moving it to the Recycle Bin. Refresh any shell listeners. /// /// The item to delete. - /// Set to false (the default) to send the file to the Recycle Bin, - /// if possible. Set to true to permanently delete the item. + /// The handle of the main window. /// The result of the file operation call. A non-zero result indicates failure. /// - private int DeleteFile(IFileSystemItem item, bool permanent = false) + private int DeleteFile(IFileSystemItem item, nint hwnd) { - // We handle delete confirmations with our own dialog, so it is not required here. - ushort flags = FOF_NO_CONFIRMATION; - - if (!permanent) - { - // Move to the Recycle Bin and warn about permanent deletes. - flags |= (ushort)(FOF_ALLOWUNDO | FOF_WANTNUKEWARNING); - } + // Move to the Recycle Bin and warn about permanent deletes. + var flags = (ushort)(FOF_ALLOWUNDO | FOF_WANTNUKEWARNING); SHFILEOPSTRUCT fileOp = new() { wFunc = FO_DELETE, pFrom = item.Path + "\0\0", // Path arguments must be double null-terminated. fFlags = flags, + hwnd = hwnd, }; int result = SHFileOperation(ref fileOp); diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index aa8295409d41..c6956bdcfc9f 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -100,13 +100,13 @@ private async Task DeleteItem() if (await ShowDeleteConfirmationDialogAsync() == ContentDialogResult.Primary) { // Delete after asking for confirmation. Persist the "Don't ask again" choice if set. - ViewModel.DeleteItem(DeleteDontAskCheckbox.IsChecked); + ViewModel.DeleteItem(DeleteDontAskCheckbox.IsChecked, this.GetWindowHandle()); } } else { // Delete without confirmation. - ViewModel.DeleteItem(true); + ViewModel.DeleteItem(true, this.GetWindowHandle()); } } finally From 098d912e19ef852bee22c7c61cd03323fe00565b Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Tue, 28 Jan 2025 14:51:51 +0000 Subject: [PATCH 13/14] Simplify delete confirmation dialog. Remove workaround for focus visual issue. Ensure delete confirmation dialog is closed when the main window visibility is toggled. --- .../peek/Peek.UI/MainWindowViewModel.cs | 6 --- .../peek/Peek.UI/PeekXAML/MainWindow.xaml | 2 +- .../peek/Peek.UI/PeekXAML/MainWindow.xaml.cs | 44 +++---------------- .../peek/Peek.UI/Strings/en-us/Resources.resw | 10 ++--- 4 files changed, 11 insertions(+), 51 deletions(-) diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index 689ca34eadb7..ac46365f8b79 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -66,9 +66,6 @@ partial void OnCurrentItemChanged(IFileSystemItem? value) WindowTitle = value != null ? ReadableStringHelper.FormatResourceString("WindowTitle", value.Name) : _defaultWindowTitle; - - DeleteConfirmationDialogMessage = - ReadableStringHelper.FormatResourceString("DeleteConfirmationDialog_Message", value?.Name ?? string.Empty); } [ObservableProperty] @@ -78,9 +75,6 @@ partial void OnCurrentItemChanged(IFileSystemItem? value) [NotifyPropertyChangedFor(nameof(DisplayItemCount))] private NeighboringItems? _items; - [ObservableProperty] - private string _deleteConfirmationDialogMessage = string.Empty; - /// /// The number of items selected and available to preview. Decreases as the user deletes /// items. Displayed on the title bar. diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml index 101565e01729..78879bc74cad 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -68,7 +68,7 @@ x:Uid="DeleteConfirmationDialog" DefaultButton="Close"> - + diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index c6956bdcfc9f..461ca227c163 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -31,12 +31,6 @@ public sealed partial class MainWindow : WindowEx, IDisposable private readonly ThemeListener? themeListener; - // Used to work around a focus visual issue when the delete confirmation dialog is - // triggered by a key event. - private readonly Thickness _zeroThickness = new(0); - private Thickness _defaultFocusPrimaryThickness = new(2); - private Thickness _defaultFocusSecondaryThickness = new(2); - /// /// Whether the delete confirmation dialog is currently open. Used to ensure only one /// dialog is open at a time. @@ -75,13 +69,6 @@ private async void Content_KeyUp(object sender, KeyRoutedEventArgs e) { await DeleteItem(); } - - // Restore the default focus visual. The Space key is excluded as that is used to toggle - // the checkbox when it has focus. - if (e.Key != Windows.System.VirtualKey.Space) - { - RestoreFocusThickness(); - } } private async Task DeleteItem() @@ -119,37 +106,11 @@ private async Task ShowDeleteConfirmationDialogAsync() { DeleteDontAskCheckbox.IsChecked = false; - CacheFocusThickness(); - - // Hide the default focus visual. This prevents its initial display when the dialog is - // opened via a keyboard event. - DeleteDontAskCheckbox.FocusVisualPrimaryThickness = _zeroThickness; - DeleteDontAskCheckbox.FocusVisualSecondaryThickness = _zeroThickness; - DeleteConfirmationDialog.XamlRoot = Content.XamlRoot; return await DeleteConfirmationDialog.ShowAsync(); } - /// - /// Save the current focus visual thickness. This will be restored when the user interacts - /// with the dialog, e.g. by using Tab. - /// - private void CacheFocusThickness() - { - CheckBox hiddenCheckBox = new() { Visibility = Visibility.Collapsed }; - MainGrid.Children.Add(hiddenCheckBox); - _defaultFocusPrimaryThickness = hiddenCheckBox.FocusVisualPrimaryThickness; - _defaultFocusSecondaryThickness = hiddenCheckBox.FocusVisualSecondaryThickness; - MainGrid.Children.Remove(hiddenCheckBox); - } - - private void RestoreFocusThickness() - { - DeleteDontAskCheckbox.FocusVisualPrimaryThickness = _defaultFocusPrimaryThickness; - DeleteDontAskCheckbox.FocusVisualSecondaryThickness = _defaultFocusSecondaryThickness; - } - /// /// Toggling the window visibility and querying files when necessary. /// @@ -162,6 +123,11 @@ public void Toggle(bool firstActivation, Windows.Win32.Foundation.HWND foregroun return; } + if (DeleteConfirmationDialog.Visibility == Visibility.Visible) + { + DeleteConfirmationDialog.Hide(); + } + if (AppWindow.IsVisible) { if (IsNewSingleSelectedItem(foregroundWindowHandle)) 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 11b2b6cd0b03..b24e866c8774 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -358,18 +358,18 @@ The prefix added to all file delete failure messages. {0} is replaced with the name of the file - Delete file + Delete file? - Move to Recycle Bin + Delete Cancel - - Are you sure you want to move "{0}" to the Recycle Bin? + + Are you sure you want to delete this file? - Don't ask me again + Don't show this warning again \ No newline at end of file From e03008412860a869e3a9dbb143825a2cb7e79fca Mon Sep 17 00:00:00 2001 From: Dave Rayment Date: Tue, 28 Jan 2025 18:45:03 +0000 Subject: [PATCH 14/14] Fix delete delay. Do not regard user cancellations of permanent deletes as an error, but log them as info anyway. More descriptive name for delete confirmation dialog checkbox. --- .../peek/Peek.UI/MainWindowViewModel.cs | 41 ++++++++++++++----- .../peek/Peek.UI/Native/NativeMethods.cs | 5 +++ .../peek/Peek.UI/PeekXAML/MainWindow.xaml | 2 +- .../peek/Peek.UI/PeekXAML/MainWindow.xaml.cs | 7 ++-- .../peek/Peek.UI/Strings/en-us/Resources.resw | 2 +- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index ac46365f8b79..3bd809630dfb 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -226,24 +226,36 @@ public void DeleteItem(bool? skipConfirmationChecked, nint hwnd) OnPropertyChanged(nameof(DisplayItemCount)); // Attempt the deletion then navigate to the next file. - DispatcherQueue.GetForCurrentThread().TryEnqueue(() => + DispatcherQueue.GetForCurrentThread().TryEnqueue(async () => { - Task.Delay(DeleteDelayMs); + await Task.Delay(DeleteDelayMs); int result = DeleteFile(item, hwnd); - if (result != 0) + 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); + // Success. + return; + } - if (File.Exists(item.Path)) + if (result == ERROR_CANCELLED) + { + if (Path.GetPathRoot(item.Path) is string root) { - _deletedItemIndexes.Remove(index); - OnPropertyChanged(nameof(DisplayItemCount)); + var driveInfo = new DriveInfo(root); + Logger.LogInfo($"User cancelled deletion of \"{item.Name}\" on " + + $"{driveInfo.DriveType} drive."); } } + else + { + // For failures other than user cancellation, log the error and show a message + // in the UI. + DeleteErrorMessageHelper.LogError(result); + ShowDeleteError(item.Name, result); + } + + // For all errors, reinstate the deleted file if it still exists. + ReinstateDeletedFile(item, index); }); Navigate(_navigationDirection, isAfterDelete: true); @@ -278,6 +290,15 @@ private int DeleteFile(IFileSystemItem item, nint hwnd) return result; } + private void ReinstateDeletedFile(IFileSystemItem item, int index) + { + if (File.Exists(item.Path)) + { + _deletedItemIndexes.Remove(index); + OnPropertyChanged(nameof(DisplayItemCount)); + } + } + /// /// Informs shell listeners like Explorer windows that a delete operation has occurred. /// diff --git a/src/modules/peek/Peek.UI/Native/NativeMethods.cs b/src/modules/peek/Peek.UI/Native/NativeMethods.cs index 1fe0035f3e54..35040e16482f 100644 --- a/src/modules/peek/Peek.UI/Native/NativeMethods.cs +++ b/src/modules/peek/Peek.UI/Native/NativeMethods.cs @@ -94,6 +94,11 @@ internal struct SHFILEOPSTRUCT /// Declared in shellapi.h./remarks> internal const ushort FOF_WANTNUKEWARNING = 0x4000; + /// + /// The user cancelled the delete operation. Not classified as an error for our purposes. + /// + internal const int ERROR_CANCELLED = 1223; + /// /// Common error codes when calling SHFileOperation to delete a file. /// diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml index 78879bc74cad..539baf9471df 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -69,7 +69,7 @@ DefaultButton="Close"> - + diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index 461ca227c163..5af4a7bce2dd 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -86,8 +86,8 @@ private async Task DeleteItem() { if (await ShowDeleteConfirmationDialogAsync() == ContentDialogResult.Primary) { - // Delete after asking for confirmation. Persist the "Don't ask again" choice if set. - ViewModel.DeleteItem(DeleteDontAskCheckbox.IsChecked, this.GetWindowHandle()); + // Delete after asking for confirmation. Persist the "Don't warn again" choice if set. + ViewModel.DeleteItem(DeleteDontWarnCheckbox.IsChecked, this.GetWindowHandle()); } } else @@ -104,8 +104,7 @@ private async Task DeleteItem() private async Task ShowDeleteConfirmationDialogAsync() { - DeleteDontAskCheckbox.IsChecked = false; - + DeleteDontWarnCheckbox.IsChecked = false; DeleteConfirmationDialog.XamlRoot = Content.XamlRoot; return await DeleteConfirmationDialog.ShowAsync(); 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 b24e866c8774..4ffec5f68505 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -369,7 +369,7 @@ Are you sure you want to delete this file? - + Don't show this warning again \ No newline at end of file