Skip to content

Commit

Permalink
Add delete confirmation dialog and settings to Peek. Add shell notifi…
Browse files Browse the repository at this point in the history
…cation event after delete operation.
  • Loading branch information
daverayment committed Jan 26, 2025
1 parent 441898a commit 36882f4
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 32 deletions.
70 changes: 66 additions & 4 deletions src/modules/peek/Peek.UI/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using ManagedCommon;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Peek.Common.Extensions;
using Peek.Common.Helpers;
using Peek.Common.Models;
using Peek.UI.Helpers;
Expand Down Expand Up @@ -63,6 +66,9 @@ partial void OnCurrentItemChanged(IFileSystemItem? value)
WindowTitle = value != null
? ReadableStringHelper.FormatResourceString("WindowTitle", value.Name)
: _defaultWindowTitle;

DeleteConfirmationDialogMessage =
ReadableStringHelper.FormatResourceString("DeleteConfirmationDialog_Message", value?.Name ?? string.Empty);
}

[ObservableProperty]
Expand All @@ -72,6 +78,9 @@ partial void OnCurrentItemChanged(IFileSystemItem? value)
[NotifyPropertyChangedFor(nameof(DisplayItemCount))]
private NeighboringItems? _items;

[ObservableProperty]
private string _deleteConfirmationDialogMessage = string.Empty;

/// <summary>
/// The number of items selected and available to preview. Decreases as the user deletes
/// items. Displayed on the title bar.
Expand Down Expand Up @@ -196,13 +205,19 @@ private void Navigate(NavigationDirection direction, bool isAfterDelete = false)
/// <summary>
/// Sends the current item to the Recycle Bin.
/// </summary>
public void DeleteItem()
/// <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)
{
if (CurrentItem == null)
{
return;
}

bool skipConfirmation = skipConfirmationChecked ?? false;
bool shouldShowConfirmation = !skipConfirmation;
Application.Current.GetService<IUserSettings>().ConfirmFileDelete = shouldShowConfirmation;

var item = CurrentItem;

if (File.Exists(item.Path) && !IsFilePath(item.Path))
Expand Down Expand Up @@ -240,16 +255,63 @@ public void DeleteItem()
Navigate(_navigationDirection, isAfterDelete: true);
}

/// <summary>
/// Delete a file and refresh any shell listeners.
/// </summary>
/// <param name="item">The item to delete.</param>
/// <param name="permanent">Set to false (the default) to send the file to the Recycle Bin,
/// if possible. Set to true to permanently delete the item.</param>
/// <returns>The result of the file operation call. A non-zero result indicates failure.
/// </returns>
private int DeleteFile(IFileSystemItem item, bool permanent = false)
{
// We handle delete confirmations with our own dialog, so it is not required here.
ushort flags = FOF_NO_CONFIRMATION;

if (!permanent)
{
// Move to the Recycle Bin and warn about permanent deletes.
flags |= (ushort)(FOF_ALLOWUNDO | FOF_WANT_NUKE_WARNING);
}

SHFILEOPSTRUCT fileOp = new()
{
wFunc = FO_DELETE,
pFrom = item.Path + "\0\0",
fFlags = (ushort)(FOF_NO_CONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)),
pFrom = item.Path + "\0\0", // Path arguments must be double null-terminated.
fFlags = flags,
};

return SHFileOperation(ref fileOp);
int result = SHFileOperation(ref fileOp);
if (result == 0)
{
SendDeleteChangeNotification(item.Path);
}

return result;
}

/// <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)
Expand Down
41 changes: 34 additions & 7 deletions src/modules/peek/Peek.UI/Native/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ public enum AssocStr
/// <summary>
/// Shell File Operations structure. Used for file deletion.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct SHFILEOPSTRUCT
{
public IntPtr hwnd;
public int wFunc;
public uint wFunc;
public string pFrom;
public string pTo;
public ushort fFlags;
Expand All @@ -68,23 +68,31 @@ internal struct SHFILEOPSTRUCT
public string lpszProgressTitle;
}

[DllImport("shell32.dll", CharSet = CharSet.Auto)]
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
internal static extern int SHFileOperation(ref SHFILEOPSTRUCT fileOp);

/// <summary>
/// File delete operation.
/// </summary>
internal const int FO_DELETE = 0x0003;
internal const uint FO_DELETE = 0x0003;

/// <summary>
/// Send to Recycle Bin flag.
/// </summary>
internal const int FOF_ALLOWUNDO = 0x0040;
internal const ushort FOF_ALLOWUNDO = 0x0040;

/// <summary>
/// Do not request user confirmation for file deletes.
/// </summary>
internal const ushort FOF_NO_CONFIRMATION = 0x0010;

/// <summary>
/// Do not request user confirmation for file delete flag.
/// Warn if a file cannot be recycled and would instead be permanently deleted. (Partially
/// overrides FOF_NO_CONFIRMATION.) This can be tested by attempting to delete a file on a
/// FAT volume, e.g. a USB key.
/// </summary>
internal const int FOF_NO_CONFIRMATION = 0x0010;
/// <remarks>FOF_WANTNUKEWARNING in shellapi.h./remarks>

Check warning on line 94 in src/modules/peek/Peek.UI/Native/NativeMethods.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`WANTNUKEWARNING` is not a recognized word. (unrecognized-spelling)

Check warning on line 94 in src/modules/peek/Peek.UI/Native/NativeMethods.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`shellapi` is not a recognized word. (unrecognized-spelling)
internal const ushort FOF_WANT_NUKE_WARNING = 0x4000;

/// <summary>
/// Common error codes when calling SHFileOperation to delete a file.
Expand All @@ -99,5 +107,24 @@ internal struct SHFILEOPSTRUCT
{ 32, "The process cannot access the file because it is being used by another process." },
{ 33, "The process cannot access the file because another process has locked a portion of the file." },
};

/// <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;
}
}
12 changes: 11 additions & 1 deletion src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down Expand Up @@ -62,5 +62,15 @@
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 VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Spacing="12">
<TextBlock TextWrapping="Wrap" Text="{x:Bind ViewModel.DeleteConfirmationDialogMessage, Mode=OneWay}" />
<CheckBox x:Name="DeleteDontAskCheckbox" x:Uid="DeleteConfirmationDialog_DontAskCheckbox" />
</StackPanel>
</ContentDialog>
</Grid>
</winuiex:WindowEx>
92 changes: 89 additions & 3 deletions src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
// See the LICENSE file in the project root for more information.

using System;

using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Peek.Common.Constants;
using Peek.Common.Extensions;
Expand All @@ -30,6 +31,18 @@ public sealed partial class MainWindow : WindowEx, IDisposable

private readonly ThemeListener? themeListener;

// Used to work around a focus visual issue when the delete confirmation dialog is
// triggered by a key event.
private readonly Thickness _zeroThickness = new(0);
private Thickness _defaultFocusPrimaryThickness = new(2);
private Thickness _defaultFocusSecondaryThickness = new(2);

/// <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();
Expand All @@ -56,12 +69,85 @@ public MainWindow()
AppWindow.Closing += AppWindow_Closing;
}

private void Content_KeyUp(object sender, KeyRoutedEventArgs e)
private async void Content_KeyUp(object sender, KeyRoutedEventArgs e)
{
if (e.Key == Windows.System.VirtualKey.Delete)
{
this.ViewModel.DeleteItem();
await DeleteItem();
}

// Restore the default focus visual. The Space key is excluded as that is used to toggle
// the checkbox when it has focus.
if (e.Key != Windows.System.VirtualKey.Space)
{
RestoreFocusThickness();
}
}

private async Task DeleteItem()
{
if (ViewModel.CurrentItem == null || _isDeleteInProgress)
{
return;
}

try
{
_isDeleteInProgress = true;

if (Application.Current.GetService<IUserSettings>().ConfirmFileDelete)
{
if (await ShowDeleteConfirmationDialogAsync() == ContentDialogResult.Primary)
{
// Delete after asking for confirmation. Persist the "Don't ask again" choice if set.
ViewModel.DeleteItem(DeleteDontAskCheckbox.IsChecked);
}
}
else
{
// Delete without confirmation.
ViewModel.DeleteItem(true);
}
}
finally
{
_isDeleteInProgress = false;
}
}

private async Task<ContentDialogResult> ShowDeleteConfirmationDialogAsync()
{
DeleteDontAskCheckbox.IsChecked = false;

CacheFocusThickness();

// Hide the default focus visual. This prevents its initial display when the dialog is
// opened via a keyboard event.
DeleteDontAskCheckbox.FocusVisualPrimaryThickness = _zeroThickness;
DeleteDontAskCheckbox.FocusVisualSecondaryThickness = _zeroThickness;

DeleteConfirmationDialog.XamlRoot = Content.XamlRoot;

return await DeleteConfirmationDialog.ShowAsync();
}

/// <summary>
/// Save the current focus visual thickness. This will be restored when the user interacts
/// with the dialog, e.g. by using Tab.
/// </summary>
private void CacheFocusThickness()
{
CheckBox hiddenCheckBox = new() { Visibility = Visibility.Collapsed };
MainGrid.Children.Add(hiddenCheckBox);
_defaultFocusPrimaryThickness = hiddenCheckBox.FocusVisualPrimaryThickness;
_defaultFocusSecondaryThickness = hiddenCheckBox.FocusVisualSecondaryThickness;
MainGrid.Children.Remove(hiddenCheckBox);
}

private void RestoreFocusThickness()
{
DeleteDontAskCheckbox.FocusVisualPrimaryThickness = _defaultFocusPrimaryThickness;
DeleteDontAskCheckbox.FocusVisualSecondaryThickness = _defaultFocusSecondaryThickness;
}

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions src/modules/peek/Peek.UI/Services/IUserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ namespace Peek.UI
public interface IUserSettings
{
public bool CloseAfterLosingFocus { get; }

public bool ConfirmFileDelete { get; set; }
}
}
Loading

0 comments on commit 36882f4

Please sign in to comment.