diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 27028e7594..6907f937c5 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -490,6 +490,7 @@ flac flyouts FMask FOF +WANTNUKEWARNING FOFX FOLDERID folderpath @@ -1418,6 +1419,7 @@ SHCNE SHCNF SHCONTF Shcore +shellapi SHELLDETAILS SHELLDLL shellex @@ -1812,6 +1814,7 @@ windowssearch windowssettings WINDOWSTYLES WINDOWSTYLESICON +winerror WINEVENT winget wingetcreate diff --git a/Directory.Packages.props b/Directory.Packages.props index 8cd7cea417..9698a4adb7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,4 +98,4 @@ - + \ No newline at end of file diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 9943a6962d..c3bbe50c61 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -102,6 +102,16 @@ LoadingState="{x:Bind UnsupportedFilePreviewer.State, Mode=OneWay}" Source="{x:Bind UnsupportedFilePreviewer.Preview, Mode=OneWay}" Visibility="{x:Bind IsUnsupportedPreviewVisible(UnsupportedFilePreviewer, Previewer.State), Mode=OneWay}" /> + + await ((FilePreview)d).OnScalingFactorPropertyChanged())); + [ObservableProperty] + private int numberOfFiles; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ImagePreviewer))] [NotifyPropertyChangedFor(nameof(VideoPreviewer))] @@ -62,6 +65,9 @@ namespace Peek.FilePreviewer [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 @@ namespace Peek.FilePreviewer // Clear up any unmanaged resources before creating a new previewer instance. (Previewer as IDisposable)?.Dispose(); + NoMoreFiles.Visibility = NumberOfFiles == 0 ? Visibility.Visible : Visibility.Collapsed; + if (Item == null) { Previewer = null; diff --git a/src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs b/src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs new file mode 100644 index 0000000000..8c062a80ce --- /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 d129949f36..db54e346cc 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -3,26 +3,62 @@ // See the LICENSE file in the project root for more information. using System; -using System.Linq; +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; 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 +73,49 @@ namespace Peek.UI private string _windowTitle; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayItemCount))] private NeighboringItems? _items; + /// + /// The number of items selected and available to preview. Decreases as the user deletes + /// items. Displayed on the title bar. + /// + private int _displayItemCount; + + public int DisplayItemCount + { + get => Items?.Count - _deletedItemIndexes.Count ?? 0; + set + { + if (_displayItemCount != value) + { + _displayItemCount = value; + OnPropertyChanged(); + } + } + } + [ObservableProperty] private double _scalingFactor = 1.0; + [ObservableProperty] + private string _errorMessage = string.Empty; + + [ObservableProperty] + private bool _isErrorVisible = false; + + private enum NavigationDirection + { + Forwards, + Backwards, + } + + /// + /// The current direction in which the user is moving through the items collection. + /// Determines how we act when a file is deleted. + /// + private NavigationDirection _navigationDirection = NavigationDirection.Forwards; + public NeighboringItemsQuery NeighboringItemsQuery { get; } private DispatcherTimer NavigationThrottleTimer { get; set; } = new(); @@ -63,50 +137,215 @@ namespace Peek.UI } catch (Exception ex) { - Logger.LogError("Failed to get File Explorer Items: " + ex.Message); + Logger.LogError("Failed to get File Explorer Items.", ex); } - CurrentIndex = 0; + _currentIndex = DisplayIndex = 0; - if (Items != null && Items.Count > 0) - { - CurrentItem = Items[0]; - } + CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null; } public void Uninitialize() { - CurrentIndex = 0; + _currentIndex = DisplayIndex = 0; CurrentItem = null; + _deletedItemIndexes.Clear(); Items = null; + _navigationDirection = NavigationDirection.Forwards; + IsErrorVisible = false; } - public void AttemptPreviousNavigation() + public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards); + + public void AttemptNextNavigation() => Navigate(NavigationDirection.Forwards); + + private void Navigate(NavigationDirection direction, bool isAfterDelete = false) { if (NavigationThrottleTimer.IsEnabled) { return; } - NavigationThrottleTimer.Start(); + if (Items == null || Items.Count == _deletedItemIndexes.Count) + { + _currentIndex = DisplayIndex = 0; + CurrentItem = null; + return; + } - var itemCount = Items?.Count ?? 1; - CurrentIndex = MathHelper.Modulo(CurrentIndex - 1, itemCount); - CurrentItem = Items?.ElementAtOrDefault(CurrentIndex); + _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); + + NavigationThrottleTimer.Start(); } - public void AttemptNextNavigation() + /// + /// Sends the current item to the Recycle Bin. + /// + /// The IsChecked property of the "Don't ask me + /// again" checkbox on the delete confirmation dialog. + public void DeleteItem(bool? skipConfirmationChecked, nint hwnd) { - if (NavigationThrottleTimer.IsEnabled) + if (CurrentItem == null) { return; } - NavigationThrottleTimer.Start(); + bool skipConfirmation = skipConfirmationChecked ?? false; + bool shouldShowConfirmation = !skipConfirmation; + Application.Current.GetService().ConfirmFileDelete = shouldShowConfirmation; - var itemCount = Items?.Count ?? 1; - CurrentIndex = MathHelper.Modulo(CurrentIndex + 1, itemCount); - CurrentItem = Items?.ElementAtOrDefault(CurrentIndex); + var item = CurrentItem; + + if (File.Exists(item.Path) && !IsFilePath(item.Path)) + { + // The path is to a folder, not a file, or its attributes could not be retrieved. + return; + } + + // Update the file count and total files. + int index = _currentIndex; + _deletedItemIndexes.Add(index); + OnPropertyChanged(nameof(DisplayItemCount)); + + // Attempt the deletion then navigate to the next file. + DispatcherQueue.GetForCurrentThread().TryEnqueue(async () => + { + await Task.Delay(DeleteDelayMs); + int result = DeleteFile(item, hwnd); + + if (result == 0) + { + // Success. + return; + } + + if (result == ERROR_CANCELLED) + { + if (Path.GetPathRoot(item.Path) is string root) + { + var driveInfo = new DriveInfo(root); + Logger.LogInfo($"User cancelled item deletion 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); + } + + /// + /// Delete a file by moving it to the Recycle Bin. Refresh any shell listeners. + /// + /// The item to delete. + /// The handle of the main window. + /// The result of the file operation call. A non-zero result indicates failure. + /// + private int DeleteFile(IFileSystemItem item, nint hwnd) + { + // 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); + if (result == 0) + { + SendDeleteChangeNotification(item.Path); + } + + 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. + /// + /// 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) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + try + { + FileAttributes attributes = File.GetAttributes(path); + return (attributes & FileAttributes.Directory) != FileAttributes.Directory; + } + catch (Exception) + { + return false; + } + } + + private void ShowDeleteError(string filename, int errorCode) + { + IsErrorVisible = false; + ErrorMessage = DeleteErrorMessageHelper.GetUserErrorMessage(filename, errorCode); + IsErrorVisible = true; } private void NavigationThrottleTimer_Tick(object? sender, object e) diff --git a/src/modules/peek/Peek.UI/Models/NeighboringItems.cs b/src/modules/peek/Peek.UI/Models/NeighboringItems.cs index c528cc2b7e..b63096889f 100644 --- a/src/modules/peek/Peek.UI/Models/NeighboringItems.cs +++ b/src/modules/peek/Peek.UI/Models/NeighboringItems.cs @@ -27,14 +27,8 @@ namespace Peek.UI.Models 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 95badbae03..35040e1648 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,85 @@ namespace Peek.UI.Native [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.Unicode)] + internal struct SHFILEOPSTRUCT + { + public IntPtr hwnd; + public uint wFunc; + public string pFrom; + public string pTo; + public ushort fFlags; + public bool fAnyOperationsAborted; + public IntPtr hNameMappings; + public string lpszProgressTitle; + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + internal static extern int SHFileOperation(ref SHFILEOPSTRUCT fileOp); + + /// + /// File delete operation. + /// + internal const uint FO_DELETE = 0x0003; + + /// + /// Send to Recycle Bin flag. + /// + internal const ushort FOF_ALLOWUNDO = 0x0040; + + /// + /// Do not request user confirmation for file deletes. + /// + internal const ushort FOF_NO_CONFIRMATION = 0x0010; + + /// + /// 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. + /// + /// 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. + /// + /// 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." }, + }; + + /// + /// 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 eeb47aaf97..f8e3166b0a 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -1,4 +1,4 @@ - + - + @@ -34,20 +34,46 @@ + + 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 7295dd1dcc..03e48c5ceb 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -3,12 +3,14 @@ // 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 +32,12 @@ namespace Peek.UI private readonly ThemeListener? themeListener; + /// + /// 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,6 +64,54 @@ namespace Peek.UI AppWindow.Closing += AppWindow_Closing; } + private async void Content_KeyUp(object sender, KeyRoutedEventArgs e) + { + if (e.Key == Windows.System.VirtualKey.Delete) + { + e.Handled = true; + await DeleteItem(); + } + } + + 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 warn again" choice if set. + ViewModel.DeleteItem(DeleteDontWarnCheckbox.IsChecked, this.GetWindowHandle()); + } + } + else + { + // Delete without confirmation. + ViewModel.DeleteItem(true, this.GetWindowHandle()); + } + } + finally + { + _isDeleteInProgress = false; + } + } + + private async Task ShowDeleteConfirmationDialogAsync() + { + DeleteDontWarnCheckbox.IsChecked = false; + DeleteConfirmationDialog.XamlRoot = Content.XamlRoot; + + return await DeleteConfirmationDialog.ShowAsync(); + } + /// /// Toggling the window visibility and querying files when necessary. /// @@ -68,6 +124,11 @@ namespace Peek.UI return; } + if (DeleteConfirmationDialog.Visibility == Visibility.Visible) + { + DeleteConfirmationDialog.Hide(); + } + if (AppWindow.IsVisible) { if (IsNewSingleSelectedItem(foregroundWindowHandle)) @@ -127,6 +188,7 @@ namespace Peek.UI ViewModel.Initialize(foregroundWindowHandle); ViewModel.ScalingFactor = this.GetMonitorScale(); + this.Content.KeyUp += Content_KeyUp; bootTime.Stop(); @@ -140,6 +202,8 @@ namespace Peek.UI 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 2d768dcf36..57e073d8a5 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 7e2759cc5d..9b2fd98dda 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 @@ namespace Peek.UI.Views 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 @@ namespace Peek.UI.Views [ObservableProperty] private string? fileCountText; + [ObservableProperty] + private string fileName = string.Empty; + [ObservableProperty] private string defaultAppName = string.Empty; @@ -242,28 +245,40 @@ namespace Peek.UI.Views 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/Services/IUserSettings.cs b/src/modules/peek/Peek.UI/Services/IUserSettings.cs index bde6f85173..65d5e67f18 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 e4a73353c1..77257eaf80 100644 --- a/src/modules/peek/Peek.UI/Services/UserSettings.cs +++ b/src/modules/peek/Peek.UI/Services/UserSettings.cs @@ -23,11 +23,58 @@ namespace Peek.UI [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0052:Remove unread private members", Justification = "Defined in helper called in constructor.")] private readonly IFileSystemWatcher _watcher; + /// + /// The current settings. Initially set to defaults. + /// + private PeekSettings _settings = new(); + + private PeekSettings Settings + { + get => _settings; + set + { + lock (_settingsLock) + { + _settings = value; + CloseAfterLosingFocus = _settings.Properties.CloseAfterLosingFocus.Value; + ConfirmFileDelete = _settings.Properties.ConfirmFileDelete.Value; + } + } + } + /// /// Gets a value indicating whether Peek closes automatically when the window loses focus. /// public bool CloseAfterLosingFocus { get; private set; } + private bool _confirmFileDelete; + + /// + /// 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 + { + get => _confirmFileDelete; + set + { + if (_confirmFileDelete != value) + { + _confirmFileDelete = value; + + // We write directly to the settings file. The Settings UI will pick detect + // this change via its file watcher and update accordingly. This is the only + // setting that is modified by Peek itself. + lock (_settingsLock) + { + _settings.Properties.ConfirmFileDelete.Value = _confirmFileDelete; + _settingsUtils.SaveSettings(_settings.ToJsonString(), PeekModuleName); + } + } + } + } + public UserSettings() { _settingsUtils = new SettingsUtils(); @@ -37,26 +84,13 @@ namespace Peek.UI _watcher = Helper.GetFileWatcher(PeekModuleName, SettingsUtils.DefaultFileName, LoadSettingsFromJson); } - private void ApplySettings(PeekSettings settings) - { - lock (_settingsLock) - { - CloseAfterLosingFocus = settings.Properties.CloseAfterLosingFocus.Value; - } - } - - private void ApplyDefaultSettings() - { - ApplySettings(new PeekSettings()); - } - private void LoadSettingsFromJson() { for (int attempt = 1; attempt <= MaxAttempts; attempt++) { try { - ApplySettings(_settingsUtils.GetSettingsOrDefault(PeekModuleName)); + Settings = _settingsUtils.GetSettingsOrDefault(PeekModuleName); return; } catch (System.IO.IOException ex) @@ -65,8 +99,7 @@ namespace Peek.UI 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 +109,10 @@ namespace Peek.UI { // 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 97b81db0b4..4ffec5f685 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,47 @@ Toggle minimap + + 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 + + + Delete file? + + + Delete + + + Cancel + + + Are you sure you want to delete this file? + + + Don't show this warning again + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI.Library/ISettingsUtils.cs b/src/settings-ui/Settings.UI.Library/ISettingsUtils.cs index 10e8bf26ac..3d3ef95f06 100644 --- a/src/settings-ui/Settings.UI.Library/ISettingsUtils.cs +++ b/src/settings-ui/Settings.UI.Library/ISettingsUtils.cs @@ -10,21 +10,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library { public interface ISettingsUtils { - T GetSettings(string powertoy = "", string fileName = "settings.json") + public const string DefaultFileName = "settings.json"; + + T GetSettings(string powertoy = "", string fileName = DefaultFileName) where T : ISettingsConfig, new(); - T GetSettingsOrDefault(string powertoy = "", string fileName = "settings.json") + T GetSettingsOrDefault(string powertoy = "", string fileName = DefaultFileName) where T : ISettingsConfig, new(); - void SaveSettings(string jsonSettings, string powertoy = "", string fileName = "settings.json"); + void SaveSettings(string jsonSettings, string powertoy = "", string fileName = DefaultFileName); - bool SettingsExists(string powertoy = "", string fileName = "settings.json"); + bool SettingsExists(string powertoy = "", string fileName = DefaultFileName); void DeleteSettings(string powertoy = ""); - string GetSettingsFilePath(string powertoy = "", string fileName = "settings.json"); + string GetSettingsFilePath(string powertoy = "", string fileName = DefaultFileName); - T GetSettingsOrDefault(string powertoy = "", string fileName = "settings.json", Func settingsUpgrader = null) + T GetSettingsOrDefault(string powertoy = "", string fileName = DefaultFileName, Func settingsUpgrader = null) where T : ISettingsConfig, new() where T2 : ISettingsConfig, new(); } diff --git a/src/settings-ui/Settings.UI.Library/PeekProperties.cs b/src/settings-ui/Settings.UI.Library/PeekProperties.cs index 1e4514d866..f81a3bc9a6 100644 --- a/src/settings-ui/Settings.UI.Library/PeekProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PeekProperties.cs @@ -18,6 +18,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library ActivationShortcut = DefaultActivationShortcut; AlwaysRunNotElevated = new BoolProperty(true); CloseAfterLosingFocus = new BoolProperty(false); + ConfirmFileDelete = new BoolProperty(true); } public HotkeySettings ActivationShortcut { get; set; } @@ -26,6 +27,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library 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 a0339f35cb..1bf46dec07 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/SettingsXAML/Views/PeekPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs index 7c3841abce..24ca93208a 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs @@ -16,7 +16,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views public PeekPage() { var settingsUtils = new SettingsUtils(); - ViewModel = new PeekViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); + ViewModel = new PeekViewModel( + settingsUtils, + SettingsRepository.GetInstance(settingsUtils), + ShellPage.SendDefaultIPCMessage, + DispatcherQueue); DataContext = ViewModel; InitializeComponent(); } 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 f60f1f0910..1f59d5160c 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -3050,6 +3050,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 cfe0aa2fed..a96a1aeec5 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs @@ -4,63 +4,105 @@ using System; using System.Globalization; +using System.IO; +using System.IO.Abstractions; using System.Text.Json; using global::PowerToys.GPOWrapper; +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.UI.Dispatching; using Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class PeekViewModel : Observable + public class PeekViewModel : Observable, IDisposable { private bool _isEnabled; + private bool _settingsUpdating; + private GeneralSettings GeneralSettingsConfig { get; set; } + private readonly DispatcherQueue _dispatcherQueue; + private readonly ISettingsUtils _settingsUtils; - private readonly PeekSettings _peekSettings; private readonly PeekPreviewSettings _peekPreviewSettings; + private PeekSettings _peekSettings; private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _enabledStateIsGPOConfigured; private Func SendConfigMSG { get; } - public PeekViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, Func ipcMSGCallBackFunc) + private IFileSystemWatcher _watcher; + + public PeekViewModel( + ISettingsUtils settingsUtils, + ISettingsRepository settingsRepository, + Func ipcMSGCallBackFunc, + DispatcherQueue dispatcherQueue) { // To obtain the general settings configurations of PowerToys Settings. ArgumentNullException.ThrowIfNull(settingsRepository); GeneralSettingsConfig = settingsRepository.SettingsConfig; - _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); - if (_settingsUtils.SettingsExists(PeekSettings.ModuleName)) - { - _peekSettings = _settingsUtils.GetSettingsOrDefault(PeekSettings.ModuleName); - } - else - { - _peekSettings = new PeekSettings(); - } + _dispatcherQueue = dispatcherQueue ?? throw new ArgumentNullException(nameof(dispatcherQueue)); - if (_settingsUtils.SettingsExists(PeekSettings.ModuleName, PeekPreviewSettings.FileName)) - { - _peekPreviewSettings = _settingsUtils.GetSettingsOrDefault(PeekSettings.ModuleName, PeekPreviewSettings.FileName); - } - else - { - _peekPreviewSettings = new PeekPreviewSettings(); - } + _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); + + // Load the application-specific settings, including preview items. + _peekSettings = _settingsUtils.GetSettingsOrDefault(PeekSettings.ModuleName); + _peekPreviewSettings = _settingsUtils.GetSettingsOrDefault(PeekSettings.ModuleName, PeekPreviewSettings.FileName); + SetupSettingsFileWatcher(); InitializeEnabledValue(); SendConfigMSG = ipcMSGCallBackFunc; } + /// + /// Set up the file watcher for the settings file. Used to respond to updates to the + /// ConfirmFileDelete setting by the user within the Peek application itself. + /// + private void SetupSettingsFileWatcher() + { + string settingsPath = _settingsUtils.GetSettingsFilePath(PeekSettings.ModuleName); + + _watcher = Helper.GetFileWatcher(PeekSettings.ModuleName, SettingsUtils.DefaultFileName, () => + { + try + { + _settingsUpdating = true; + var newSettings = _settingsUtils.GetSettings(PeekSettings.ModuleName); + + _dispatcherQueue.TryEnqueue(() => + { + try + { + ConfirmFileDelete = newSettings.Properties.ConfirmFileDelete.Value; + _peekSettings = newSettings; + } + finally + { + // Only clear the flag once the UI update is complete. + _settingsUpdating = false; + } + }); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load Peek settings: {ex.Message}", ex); + _settingsUpdating = false; + } + }); + } + private void InitializeEnabledValue() { _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredPeekEnabledValue(); @@ -147,6 +189,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + 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; @@ -219,7 +275,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private void NotifySettingsChanged() { - // Using InvariantCulture as this is an IPC message + // Do not send IPC message if the settings file has been updated by Peek itself. + if (_settingsUpdating) + { + return; + } + + // This message will be intercepted by the runner, which passes the serialized JSON to + // Peek.set_config() in the C++ Peek project, which then saves it to file. SendConfigMSG( string.Format( CultureInfo.InvariantCulture, @@ -238,5 +301,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels InitializeEnabledValue(); OnPropertyChanged(nameof(IsEnabled)); } + + public void Dispose() + { + _watcher?.Dispose(); + + GC.SuppressFinalize(this); + } } }