diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml
index 9943a6962d87..c3bbe50c61fd 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))]
@@ -158,6 +161,8 @@ private async Task OnItemPropertyChanged()
// Clear up any unmanaged resources before creating a new previewer instance.
(Previewer as IDisposable)?.Dispose();
+ NoMoreFiles.Visibility = NumberOfFiles == 0 ? Visibility.Visible : Visibility.Collapsed;
+
if (Item == null)
{
Previewer = null;
diff --git a/src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs b/src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs
new file mode 100644
index 000000000000..8c062a80ce6b
--- /dev/null
+++ b/src/modules/peek/Peek.UI/Helpers/DeleteErrorMessageHelper.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using ManagedCommon;
+using static Peek.Common.Helpers.ResourceLoaderInstance;
+
+namespace Peek.UI.Helpers;
+
+public static class DeleteErrorMessageHelper
+{
+ ///
+ /// The "Could not delete 'filename'." message, which begins every user-facing error string.
+ ///
+ private static readonly CompositeFormat UserMessagePrefix =
+ CompositeFormat.Parse(ResourceLoader.GetString("DeleteFileError_Prefix") + " ");
+
+ ///
+ /// The message displayed if the delete failed but the error code isn't covered in the
+ /// collection.
+ ///
+ private static readonly string GenericErrorMessage = ResourceLoader.GetString("DeleteFileError_Generic");
+
+ ///
+ /// The collection of the most common error codes with their matching log messages and user-
+ /// facing descriptions.
+ ///
+ private static readonly Dictionary DeleteFileErrors = new()
+ {
+ {
+ 2,
+ (
+ "The system cannot find the file specified.",
+ ResourceLoader.GetString("DeleteFileError_NotFound")
+ )
+ },
+ {
+ 3,
+ (
+ "The system cannot find the path specified.",
+ ResourceLoader.GetString("DeleteFileError_NotFound")
+ )
+ },
+ {
+ 5,
+ (
+ "Access is denied.",
+ ResourceLoader.GetString("DeleteFileError_AccessDenied")
+ )
+ },
+ {
+ 19,
+ (
+ "The media is write protected.",
+ ResourceLoader.GetString("DeleteFileError_WriteProtected")
+ )
+ },
+ {
+ 32,
+ (
+ "The process cannot access the file because it is being used by another process.",
+ ResourceLoader.GetString("DeleteFileError_FileInUse")
+ )
+ },
+ {
+ 33,
+ (
+ "The process cannot access the file because another process has locked a portion of the file.",
+ ResourceLoader.GetString("DeleteFileError_FileInUse")
+ )
+ },
+ };
+
+ ///
+ /// Logs an error message in response to a failed file deletion attempt.
+ ///
+ /// The error code returned from the delete call.
+ public static void LogError(int errorCode) =>
+ Logger.LogError(DeleteFileErrors.TryGetValue(errorCode, out var messages) ?
+ messages.LogMessage :
+ $"Error {errorCode} occurred while deleting the file.");
+
+ ///
+ /// Gets the message to display in the UI for a specific delete error code.
+ ///
+ /// The name of the file which could not be deleted.
+ /// The error code result from the delete call.
+ /// A string containing the message to show in the user interface.
+ public static string GetUserErrorMessage(string filename, int errorCode)
+ {
+ string prefix = string.Format(CultureInfo.InvariantCulture, UserMessagePrefix, filename);
+
+ return DeleteFileErrors.TryGetValue(errorCode, out var messages) ?
+ prefix + messages.UserMessage :
+ prefix + GenericErrorMessage;
+ }
+}
diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs
index d129949f3680..5e4b3545a218 100644
--- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs
+++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs
@@ -3,26 +3,58 @@
// See the LICENSE file in the project root for more information.
using System;
-using System.Linq;
-
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using ManagedCommon;
+using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Peek.Common.Helpers;
using Peek.Common.Models;
+using Peek.UI.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 +69,49 @@ partial void OnCurrentItemChanged(IFileSystemItem? value)
private string _windowTitle;
[ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DisplayItemCount))]
private NeighboringItems? _items;
+ ///
+ /// The number of items selected and available to preview. Decreases as the user deletes
+ /// items. Displayed on the title bar.
+ ///
+ private int _displayItemCount;
+
+ public int DisplayItemCount
+ {
+ get => Items?.Count - _deletedItemIndexes.Count ?? 0;
+ set
+ {
+ if (_displayItemCount != value)
+ {
+ _displayItemCount = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+
[ObservableProperty]
private double _scalingFactor = 1.0;
+ [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 +133,148 @@ public void Initialize(HWND foregroundWindowHandle)
}
catch (Exception ex)
{
- Logger.LogError("Failed to get File Explorer Items: " + ex.Message);
+ Logger.LogError("Failed to get File Explorer Items.", ex);
}
- CurrentIndex = 0;
+ _currentIndex = DisplayIndex = 0;
- if (Items != null && Items.Count > 0)
- {
- CurrentItem = Items[0];
- }
+ CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null;
}
public void Uninitialize()
{
- CurrentIndex = 0;
+ _currentIndex = DisplayIndex = 0;
CurrentItem = null;
+ _deletedItemIndexes.Clear();
Items = null;
+ _navigationDirection = NavigationDirection.Forwards;
+ 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;
+ }
+
+ _navigationDirection = direction;
+
+ int offset = direction == NavigationDirection.Forwards ? 1 : -1;
+
+ do
+ {
+ _currentIndex = MathHelper.Modulo(_currentIndex + offset, Items.Count);
+ }
+ while (_deletedItemIndexes.Contains(_currentIndex));
- var itemCount = Items?.Count ?? 1;
- CurrentIndex = MathHelper.Modulo(CurrentIndex - 1, itemCount);
- CurrentItem = Items?.ElementAtOrDefault(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.
+ ///
+ public void DeleteItem()
{
- if (NavigationThrottleTimer.IsEnabled)
+ if (CurrentItem == null)
{
return;
}
- NavigationThrottleTimer.Start();
+ 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(() =>
+ {
+ Task.Delay(DeleteDelayMs);
+ int result = DeleteFile(item);
+
+ if (result != 0)
+ {
+ // On failure, log the error, show a message in the UI, and reinstate the
+ // deleted file if it still exists.
+ DeleteErrorMessageHelper.LogError(result);
+ ShowDeleteError(item.Name, result);
+
+ if (File.Exists(item.Path))
+ {
+ _deletedItemIndexes.Remove(index);
+ OnPropertyChanged(nameof(DisplayItemCount));
+ }
+ }
+ });
+
+ Navigate(_navigationDirection, isAfterDelete: true);
+ }
+
+ private int DeleteFile(IFileSystemItem item, bool permanent = false)
+ {
+ SHFILEOPSTRUCT fileOp = new()
+ {
+ wFunc = FO_DELETE,
+ pFrom = item.Path + "\0\0",
+ fFlags = (ushort)(FOF_NO_CONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)),
+ };
+
+ return SHFileOperation(ref fileOp);
+ }
+
+ private static bool IsFilePath(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ return false;
+ }
- var itemCount = Items?.Count ?? 1;
- CurrentIndex = MathHelper.Modulo(CurrentIndex + 1, itemCount);
- CurrentItem = Items?.ElementAtOrDefault(CurrentIndex);
+ 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 f6a9a744f37c..b63096889f7d 100644
--- a/src/modules/peek/Peek.UI/Models/NeighboringItems.cs
+++ b/src/modules/peek/Peek.UI/Models/NeighboringItems.cs
@@ -10,7 +10,7 @@
namespace Peek.UI.Models
{
- public class NeighboringItems : IReadOnlyList
+ public partial class NeighboringItems : IReadOnlyList
{
public IFileSystemItem this[int index] => Items[index] = Items[index] ?? ShellItemArray.GetItemAt(index).ToIFileSystemItem();
@@ -27,14 +27,8 @@ public NeighboringItems(IShellItemArray shellItemArray)
Items = new IFileSystemItem[Count];
}
- public IEnumerator GetEnumerator()
- {
- return new NeighboringItemsEnumerator(this);
- }
+ public IEnumerator GetEnumerator() => new NeighboringItemsEnumerator(this);
- IEnumerator IEnumerable.GetEnumerator()
- {
- return GetEnumerator();
- }
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
diff --git a/src/modules/peek/Peek.UI/Native/NativeMethods.cs b/src/modules/peek/Peek.UI/Native/NativeMethods.cs
index 95badbae0323..ef489e79ef99 100644
--- a/src/modules/peek/Peek.UI/Native/NativeMethods.cs
+++ b/src/modules/peek/Peek.UI/Native/NativeMethods.cs
@@ -3,9 +3,9 @@
// See the LICENSE file in the project root for more information.
using System;
+using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
-
using Peek.Common.Models;
namespace Peek.UI.Native
@@ -51,5 +51,53 @@ public enum AssocStr
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
internal static extern int GetClassName(IntPtr hWnd, StringBuilder buf, int nMaxCount);
+
+ ///
+ /// Shell File Operations structure. Used for file deletion.
+ ///
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
+ internal struct SHFILEOPSTRUCT
+ {
+ public IntPtr hwnd;
+ public int wFunc;
+ public string pFrom;
+ public string pTo;
+ public ushort fFlags;
+ public bool fAnyOperationsAborted;
+ public IntPtr hNameMappings;
+ public string lpszProgressTitle;
+ }
+
+ [DllImport("shell32.dll", CharSet = CharSet.Auto)]
+ internal static extern int SHFileOperation(ref SHFILEOPSTRUCT fileOp);
+
+ ///
+ /// File delete operation.
+ ///
+ internal const int FO_DELETE = 0x0003;
+
+ ///
+ /// Send to Recycle Bin flag.
+ ///
+ internal const int FOF_ALLOWUNDO = 0x0040;
+
+ ///
+ /// Do not request user confirmation for file delete flag.
+ ///
+ internal const int FOF_NO_CONFIRMATION = 0x0010;
+
+ ///
+ /// Common error codes when calling SHFileOperation to delete a file.
+ ///
+ /// See winerror.h for full list.
+ public static readonly Dictionary DeleteFileErrors = new()
+ {
+ { 2, "The system cannot find the file specified." },
+ { 3, "The system cannot find the path specified." },
+ { 5, "Access is denied." },
+ { 19, "The media is write protected." },
+ { 32, "The process cannot access the file because it is being used by another process." },
+ { 33, "The process cannot access the file because another process has locked a portion of the file." },
+ };
}
}
diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml
index eeb47aaf975d..1d7f7b6fbdca 100644
--- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml
+++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml
@@ -1,4 +1,4 @@
-
+
+
+ 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 ae94b0cb4462..52e5f597c70d 100644
--- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs
+++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs
@@ -55,6 +55,14 @@ public MainWindow()
AppWindow.Closing += AppWindow_Closing;
}
+ private void Content_KeyUp(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == Windows.System.VirtualKey.Delete)
+ {
+ this.ViewModel.DeleteItem();
+ }
+ }
+
///
/// Toggling the window visibility and querying files when necessary.
///
@@ -125,6 +133,7 @@ private void Initialize(Windows.Win32.Foundation.HWND foregroundWindowHandle)
ViewModel.Initialize(foregroundWindowHandle);
ViewModel.ScalingFactor = this.GetMonitorScale();
+ this.Content.KeyUp += Content_KeyUp;
bootTime.Stop();
@@ -138,6 +147,8 @@ private void Uninitialize()
ViewModel.Uninitialize();
ViewModel.ScalingFactor = 1;
+
+ this.Content.KeyUp -= Content_KeyUp;
}
///
diff --git a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml
index 2d768dcf36d7..57e073d8a5b4 100644
--- a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml
+++ b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml
@@ -59,7 +59,7 @@
x:Name="AppTitle_FileName"
Grid.Column="1"
Style="{StaticResource CaptionTextBlockStyle}"
- Text="{x:Bind Item.Name, Mode=OneWay}"
+ Text="{x:Bind FileName, Mode=OneWay}"
TextWrapping="NoWrap" />
diff --git a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml.cs
index 17d9724d7999..9ed5c327dbe9 100644
--- a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml.cs
+++ b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml.cs
@@ -55,7 +55,7 @@ public sealed partial class TitleBar : UserControl
nameof(NumberOfFiles),
typeof(int),
typeof(TitleBar),
- new PropertyMetadata(null, null));
+ new PropertyMetadata(null, (d, e) => ((TitleBar)d).OnNumberOfFilesPropertyChanged()));
[ObservableProperty]
private string openWithAppText = ResourceLoaderInstance.ResourceLoader.GetString("LaunchAppButton_OpenWith_Text");
@@ -66,6 +66,9 @@ public sealed partial class TitleBar : UserControl
[ObservableProperty]
private string? fileCountText;
+ [ObservableProperty]
+ private string fileName = string.Empty;
+
[ObservableProperty]
private string defaultAppName = string.Empty;
@@ -242,28 +245,40 @@ private void UpdateTitleBarCustomization(MainWindow mainWindow)
private void OnFilePropertyChanged()
{
- if (Item == null)
- {
- return;
- }
-
UpdateFileCountText();
+ UpdateFilename();
UpdateDefaultAppToLaunch();
}
+ private void UpdateFilename()
+ {
+ FileName = Item?.Name ?? string.Empty;
+ }
+
private void OnFileIndexPropertyChanged()
{
UpdateFileCountText();
}
+ private void OnNumberOfFilesPropertyChanged()
+ {
+ UpdateFileCountText();
+ }
+
+ ///
+ /// Respond to a change in the current file being previewed or the number of files available.
+ ///
private void UpdateFileCountText()
{
- // Update file count
- if (NumberOfFiles > 1)
+ if (NumberOfFiles >= 1)
{
string fileCountTextFormat = ResourceLoaderInstance.ResourceLoader.GetString("AppTitle_FileCounts_Text");
FileCountText = string.Format(CultureInfo.InvariantCulture, fileCountTextFormat, FileIndex + 1, NumberOfFiles);
}
+ else
+ {
+ FileCountText = string.Empty;
+ }
}
private void UpdateDefaultAppToLaunch()
diff --git a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw
index c6b7945d3377..f16000d48072 100644
--- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw
+++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw
@@ -326,4 +326,32 @@
Toggle text wrapping
Toggle whether text in pane is word-wrapped
+
+ No more files to preview.
+ The message to show when there are no files remaining to preview.
+
+
+ The file cannot be found. Please check if the file has been moved, renamed, or deleted.
+ Displayed if the file or path was not found
+
+
+ Access is denied. Please ensure you have permission to delete the file.
+ Displayed if access to the file was denied when trying to delete it
+
+
+ An error occurred while deleting the file. Please try again later.
+ Displayed if the file could not be deleted and no other error code matched
+
+
+ The file is currently in use by another program. Please close any programs that might be using the file, then try again.
+ Displayed if the file could not be deleted because it is fully or partially locked by another process
+
+
+ The storage medium is write-protected. If possible, remove the write protection then try again.
+ Displayed if the file could not be deleted because it exists on non-writable media
+
+
+ Cannot delete '{0}'.
+ The prefix added to all file delete failure messages. {0} is replaced with the name of the file
+
\ No newline at end of file