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