[Peek]Add Delete functionality (#35418)

* Add Delete functionality for Peek.

* 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.

* Revert "Delete Directory.Packages.props"

This reverts commit 3a10918c9f91de64785722e4bdb33c58d1c2daea.

* Attempt to appease the spell-checking bot by renaming flag const.

* Show error message InfoBar if file deletion failed.

* Resolve XAML styling.

* XAML styling fix.

* Settings app updates for new delete confirmation setting.

* Add delete confirmation dialog and settings to Peek. Add shell notification event after delete operation.

* Spelling updates.

* Spelling update.

* Remove permanent delete parameter, YAGNI. Add hwnd parameter to delete so warning dialogs are correctly parented. Fix flags to not hide permanent delete warning.

* Simplify delete confirmation dialog. Remove workaround for focus visual issue. Ensure delete confirmation dialog is closed when the main window visibility is toggled.

* 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.

* Fix multiple Content_KeyUp events being raised for MainWindow.

* Synchronise ConfirmFileDelete setting between Peek and Settings app.

* Update following review: split System usings from others; do not log deleted item name.

* Fix XAML style
This commit is contained in:
Dave Rayment
2025-03-18 08:59:20 +00:00
committed by GitHub
parent abd6314b2e
commit 8e90d8e4c5
21 changed files with 795 additions and 90 deletions

View File

@@ -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

View File

@@ -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}" />
<TextBlock
x:Name="NoMoreFiles"
x:Uid="NoMoreFiles"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.HeadingLevel="1"
Foreground="{StaticResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Visibility="Collapsed" />
</Grid>
<UserControl.KeyboardAccelerators>
<KeyboardAccelerator

View File

@@ -47,6 +47,9 @@ namespace Peek.FilePreviewer
typeof(FilePreview),
new PropertyMetadata(false, async (d, e) => 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;

View File

@@ -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
{
/// <summary>
/// The "Could not delete 'filename'." message, which begins every user-facing error string.
/// </summary>
private static readonly CompositeFormat UserMessagePrefix =
CompositeFormat.Parse(ResourceLoader.GetString("DeleteFileError_Prefix") + " ");
/// <summary>
/// The message displayed if the delete failed but the error code isn't covered in the
/// <see cref="DeleteFileErrors"/> collection.
/// </summary>
private static readonly string GenericErrorMessage = ResourceLoader.GetString("DeleteFileError_Generic");
/// <summary>
/// The collection of the most common error codes with their matching log messages and user-
/// facing descriptions.
/// </summary>
private static readonly Dictionary<int, (string LogMessage, string UserMessage)> 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")
)
},
};
/// <summary>
/// Logs an error message in response to a failed file deletion attempt.
/// </summary>
/// <param name="errorCode">The error code returned from the delete call.</param>
public static void LogError(int errorCode) =>
Logger.LogError(DeleteFileErrors.TryGetValue(errorCode, out var messages) ?
messages.LogMessage :
$"Error {errorCode} occurred while deleting the file.");
/// <summary>
/// Gets the message to display in the UI for a specific delete error code.
/// </summary>
/// <param name="filename">The name of the file which could not be deleted.</param>
/// <param name="errorCode">The error code result from the delete call.</param>
/// <returns>A string containing the message to show in the user interface.</returns>
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;
}
}

View File

@@ -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");
/// <summary>
/// The minimum time in milliseconds between navigation events.
/// </summary>
private const int NavigationThrottleDelayMs = 100;
[ObservableProperty]
/// <summary>
/// The delay in milliseconds before a delete operation begins, to allow for navigation
/// away from the current item to occur.
/// </summary>
private const int DeleteDelayMs = 200;
/// <summary>
/// Holds the indexes of each <see cref="IFileSystemItem"/> the user has deleted.
/// </summary>
private readonly HashSet<int> _deletedItemIndexes = [];
private static readonly string _defaultWindowTitle = ResourceLoaderInstance.ResourceLoader.GetString("AppTitle/Title");
/// <summary>
/// The actual index of the current item in the items array. Does not necessarily
/// correspond to <see cref="_displayIndex"/> if one or more files have been deleted.
/// </summary>
private int _currentIndex;
/// <summary>
/// The item index to display in the titlebar.
/// </summary>
[ObservableProperty]
private int _displayIndex;
/// <summary>
/// The item to be displayed by a matching previewer. May be null if the user has deleted
/// all items.
/// </summary>
[ObservableProperty]
private IFileSystemItem? _currentItem;
@@ -37,11 +73,49 @@ namespace Peek.UI
private string _windowTitle;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DisplayItemCount))]
private NeighboringItems? _items;
/// <summary>
/// The number of items selected and available to preview. Decreases as the user deletes
/// items. Displayed on the title bar.
/// </summary>
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,
}
/// <summary>
/// The current direction in which the user is moving through the items collection.
/// Determines how we act when a file is deleted.
/// </summary>
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()
/// <summary>
/// Sends the current item to the Recycle Bin.
/// </summary>
/// <param name="skipConfirmationChecked">The IsChecked property of the "Don't ask me
/// again" checkbox on the delete confirmation dialog.</param>
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<IUserSettings>().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);
}
/// <summary>
/// Delete a file by moving it to the Recycle Bin. Refresh any shell listeners.
/// </summary>
/// <param name="item">The item to delete.</param>
/// <param name="hwnd">The handle of the main window.</param>
/// <returns>The result of the file operation call. A non-zero result indicates failure.
/// </returns>
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));
}
}
/// <summary>
/// Informs shell listeners like Explorer windows that a delete operation has occurred.
/// </summary>
/// <param name="path">Full path to the file which was deleted.</param>
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)

View File

@@ -27,14 +27,8 @@ namespace Peek.UI.Models
Items = new IFileSystemItem[Count];
}
public IEnumerator<IFileSystemItem> GetEnumerator()
{
return new NeighboringItemsEnumerator(this);
}
public IEnumerator<IFileSystemItem> GetEnumerator() => new NeighboringItemsEnumerator(this);
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}

View File

@@ -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);
/// <summary>
/// Shell File Operations structure. Used for file deletion.
/// </summary>
[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);
/// <summary>
/// File delete operation.
/// </summary>
internal const uint FO_DELETE = 0x0003;
/// <summary>
/// Send to Recycle Bin flag.
/// </summary>
internal const ushort FOF_ALLOWUNDO = 0x0040;
/// <summary>
/// Do not request user confirmation for file deletes.
/// </summary>
internal const ushort FOF_NO_CONFIRMATION = 0x0010;
/// <summary>
/// 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.
/// </summary>
/// <remarks>Declared in shellapi.h./remarks>
internal const ushort FOF_WANTNUKEWARNING = 0x4000;
/// <summary>
/// The user cancelled the delete operation. Not classified as an error for our purposes.
/// </summary>
internal const int ERROR_CANCELLED = 1223;
/// <summary>
/// Common error codes when calling SHFileOperation to delete a file.
/// </summary>
/// <remarks>See winerror.h for full list.</remarks>
public static readonly Dictionary<int, string> 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." },
};
/// <summary>
/// Shell Change Notify. Used to inform shell listeners after we've completed a file
/// operation like Delete or Move.
/// </summary>
[DllImport("shell32.dll", CharSet = CharSet.Auto)]
internal static extern void SHChangeNotify(uint wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
/// <summary>
/// File System Notification Flag, indicating that the operation was a file deletion.
/// </summary>
/// <remarks>See ShlObj_core.h for constant definitions.</remarks>
internal const uint SHCNE_DELETE = 0x00000004;
/// <summary>
/// Indicates that SHChangeNotify's dwItem1 and (optionally) dwItem2 parameters will
/// contain string paths.
/// </summary>
internal const uint SHCNF_PATH = 0x0001;
}
}

View File

@@ -1,4 +1,4 @@
<!-- Copyright (c) Microsoft Corporation and Contributors. -->
<!-- Copyright (c) Microsoft Corporation and Contributors. -->
<!-- Licensed under the MIT License. -->
<winuiex:WindowEx
@@ -18,7 +18,7 @@
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid KeyboardAcceleratorPlacementMode="Hidden">
<Grid Name="MainGrid" KeyboardAcceleratorPlacementMode="Hidden">
<Grid.KeyboardAccelerators>
<KeyboardAccelerator Key="Left" Invoked="PreviousNavigationInvoked" />
<KeyboardAccelerator Key="Up" Invoked="PreviousNavigationInvoked" />
@@ -34,20 +34,46 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<views:TitleBar
x:Name="TitleBarControl"
Grid.Row="0"
FileIndex="{x:Bind ViewModel.CurrentIndex, Mode=OneWay}"
FileIndex="{x:Bind ViewModel.DisplayIndex, Mode=OneWay}"
IsMultiSelection="{x:Bind ViewModel.NeighboringItemsQuery.IsMultipleFilesActivation, Mode=OneWay}"
Item="{x:Bind ViewModel.CurrentItem, Mode=OneWay}"
NumberOfFiles="{x:Bind ViewModel.Items.Count, Mode=OneWay}" />
NumberOfFiles="{x:Bind ViewModel.DisplayItemCount, Mode=OneWay}" />
<fp:FilePreview
Grid.Row="1"
Item="{x:Bind ViewModel.CurrentItem, Mode=OneWay}"
NumberOfFiles="{x:Bind ViewModel.DisplayItemCount, Mode=OneWay}"
PreviewSizeChanged="FilePreviewer_PreviewSizeChanged"
ScalingFactor="{x:Bind ViewModel.ScalingFactor, Mode=OneWay}" />
<InfoBar
x:Name="ErrorInfoBar"
Title="Error"
Grid.Row="1"
Grid.RowSpan="2"
Margin="4,0,4,6"
VerticalAlignment="Bottom"
IsOpen="{x:Bind ViewModel.IsErrorVisible, Mode=TwoWay}"
Message="{x:Bind ViewModel.ErrorMessage, Mode=OneWay}"
Severity="Error" />
<ContentDialog
x:Name="DeleteConfirmationDialog"
x:Uid="DeleteConfirmationDialog"
DefaultButton="Close">
<StackPanel
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Spacing="12">
<TextBlock x:Uid="DeleteConfirmationDialog_Message" TextWrapping="Wrap" />
<CheckBox x:Name="DeleteDontWarnCheckbox" x:Uid="DeleteConfirmationDialog_DontWarnCheckbox" />
</StackPanel>
</ContentDialog>
</Grid>
</winuiex:WindowEx>

View File

@@ -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;
/// <summary>
/// Whether the delete confirmation dialog is currently open. Used to ensure only one
/// dialog is open at a time.
/// </summary>
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<IUserSettings>().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<ContentDialogResult> ShowDeleteConfirmationDialogAsync()
{
DeleteDontWarnCheckbox.IsChecked = false;
DeleteConfirmationDialog.XamlRoot = Content.XamlRoot;
return await DeleteConfirmationDialog.ShowAsync();
}
/// <summary>
/// Toggling the window visibility and querying files when necessary.
/// </summary>
@@ -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;
}
/// <summary>

View File

@@ -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" />
</Grid>
</Grid>

View File

@@ -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();
}
/// <summary>
/// Respond to a change in the current file being previewed or the number of files available.
/// </summary>
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()

View File

@@ -7,5 +7,7 @@ namespace Peek.UI
public interface IUserSettings
{
public bool CloseAfterLosingFocus { get; }
public bool ConfirmFileDelete { get; set; }
}
}

View File

@@ -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;
/// <summary>
/// The current settings. Initially set to defaults.
/// </summary>
private PeekSettings _settings = new();
private PeekSettings Settings
{
get => _settings;
set
{
lock (_settingsLock)
{
_settings = value;
CloseAfterLosingFocus = _settings.Properties.CloseAfterLosingFocus.Value;
ConfirmFileDelete = _settings.Properties.ConfirmFileDelete.Value;
}
}
}
/// <summary>
/// Gets a value indicating whether Peek closes automatically when the window loses focus.
/// </summary>
public bool CloseAfterLosingFocus { get; private set; }
private bool _confirmFileDelete;
/// <summary>
/// Gets or sets a value indicating whether the user is prompted before a file is recycled.
/// </summary>
/// <remarks>The user will always be prompted when the file cannot be sent to the Recycle
/// Bin and would instead be permanently deleted.</remarks>
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<PeekSettings>(PeekModuleName));
Settings = _settingsUtils.GetSettingsOrDefault<PeekSettings>(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)

View File

@@ -329,4 +329,47 @@
<data name="ContextMenu_ToggleMinimap" xml:space="preserve">
<value>Toggle minimap</value>
</data>
<data name="NoMoreFiles" xml:space="preserve">
<value>No more files to preview.</value>
<comment>The message to show when there are no files remaining to preview.</comment>
</data>
<data name="DeleteFileError_NotFound" xml:space="preserve">
<value>The file cannot be found. Please check if the file has been moved, renamed, or deleted.</value>
<comment>Displayed if the file or path was not found</comment>
</data>
<data name="DeleteFileError_AccessDenied" xml:space="preserve">
<value>Access is denied. Please ensure you have permission to delete the file.</value>
<comment>Displayed if access to the file was denied when trying to delete it</comment>
</data>
<data name="DeleteFileError_Generic" xml:space="preserve">
<value>An error occurred while deleting the file. Please try again later.</value>
<comment>Displayed if the file could not be deleted and no other error code matched</comment>
</data>
<data name="DeleteFileError_FileInUse" xml:space="preserve">
<value>The file is currently in use by another program. Please close any programs that might be using the file, then try again.</value>
<comment>Displayed if the file could not be deleted because it is fully or partially locked by another process</comment>
</data>
<data name="DeleteFileError_WriteProtected" xml:space="preserve">
<value>The storage medium is write-protected. If possible, remove the write protection then try again.</value>
<comment>Displayed if the file could not be deleted because it exists on non-writable media</comment>
</data>
<data name="DeleteFileError_Prefix" xml:space="preserve">
<value>Cannot delete '{0}'.</value>
<comment>The prefix added to all file delete failure messages. {0} is replaced with the name of the file</comment>
</data>
<data name="DeleteConfirmationDialog.Title" xml:space="preserve">
<value>Delete file?</value>
</data>
<data name="DeleteConfirmationDialog.PrimaryButtonText" xml:space="preserve">
<value>Delete</value>
</data>
<data name="DeleteConfirmationDialog.CloseButtonText" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="DeleteConfirmationDialog_Message.Text" xml:space="preserve">
<value>Are you sure you want to delete this file?</value>
</data>
<data name="DeleteConfirmationDialog_DontWarnCheckbox.Content" xml:space="preserve">
<value>Don't show this warning again</value>
</data>
</root>

View File

@@ -10,21 +10,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library
{
public interface ISettingsUtils
{
T GetSettings<T>(string powertoy = "", string fileName = "settings.json")
public const string DefaultFileName = "settings.json";
T GetSettings<T>(string powertoy = "", string fileName = DefaultFileName)
where T : ISettingsConfig, new();
T GetSettingsOrDefault<T>(string powertoy = "", string fileName = "settings.json")
T GetSettingsOrDefault<T>(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<T, T2>(string powertoy = "", string fileName = "settings.json", Func<object, object> settingsUpgrader = null)
T GetSettingsOrDefault<T, T2>(string powertoy = "", string fileName = DefaultFileName, Func<object, object> settingsUpgrader = null)
where T : ISettingsConfig, new()
where T2 : ISettingsConfig, new();
}

View File

@@ -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);
}
}

View File

@@ -42,6 +42,9 @@
<tkcontrols:SettingsCard x:Uid="Peek_CloseAfterLosingFocus" HeaderIcon="{ui:FontIcon Glyph=&#xED1A;}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.CloseAfterLosingFocus, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="Peek_ConfirmFileDelete" HeaderIcon="{ui:FontIcon Glyph=&#xE7BA;}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.ConfirmFileDelete, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</controls:SettingsGroup>
<controls:SettingsGroup x:Uid="Peek_Preview_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">

View File

@@ -16,7 +16,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
public PeekPage()
{
var settingsUtils = new SettingsUtils();
ViewModel = new PeekViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage);
ViewModel = new PeekViewModel(
settingsUtils,
SettingsRepository<GeneralSettings>.GetInstance(settingsUtils),
ShellPage.SendDefaultIPCMessage,
DispatcherQueue);
DataContext = ViewModel;
InitializeComponent();
}

View File

@@ -3050,6 +3050,12 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Automatically close the Peek window after it loses focus</value>
<comment>Peek is a product name, do not loc</comment>
</data>
<data name="Peek_ConfirmFileDelete.Header" xml:space="preserve">
<value>Ask for confirmation before deleting files</value>
</data>
<data name="Peek_ConfirmFileDelete.Description" xml:space="preserve">
<value>When enabled, you will be prompted to confirm before moving files to the Recycle Bin.</value>
</data>
<data name="FancyZones_DisableRoundCornersOnWindowSnap.Content" xml:space="preserve">
<value>Disable round corners when window is snapped</value>
</data>

View File

@@ -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<string, int> SendConfigMSG { get; }
public PeekViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc)
private IFileSystemWatcher _watcher;
public PeekViewModel(
ISettingsUtils settingsUtils,
ISettingsRepository<GeneralSettings> settingsRepository,
Func<string, int> 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>(PeekSettings.ModuleName);
}
else
{
_peekSettings = new PeekSettings();
}
_dispatcherQueue = dispatcherQueue ?? throw new ArgumentNullException(nameof(dispatcherQueue));
if (_settingsUtils.SettingsExists(PeekSettings.ModuleName, PeekPreviewSettings.FileName))
{
_peekPreviewSettings = _settingsUtils.GetSettingsOrDefault<PeekPreviewSettings>(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>(PeekSettings.ModuleName);
_peekPreviewSettings = _settingsUtils.GetSettingsOrDefault<PeekPreviewSettings>(PeekSettings.ModuleName, PeekPreviewSettings.FileName);
SetupSettingsFileWatcher();
InitializeEnabledValue();
SendConfigMSG = ipcMSGCallBackFunc;
}
/// <summary>
/// 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.
/// </summary>
private void SetupSettingsFileWatcher()
{
string settingsPath = _settingsUtils.GetSettingsFilePath(PeekSettings.ModuleName);
_watcher = Helper.GetFileWatcher(PeekSettings.ModuleName, SettingsUtils.DefaultFileName, () =>
{
try
{
_settingsUpdating = true;
var newSettings = _settingsUtils.GetSettings<PeekSettings>(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);
}
}
}