diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0967532dc32c..720c5198937b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -76,12 +76,12 @@
-
+
-
+
diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml
index 9943a6962d87..887e8d383642 100644
--- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml
+++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml
@@ -102,6 +102,15 @@
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 @@ public sealed partial class FilePreview : UserControl, IDisposable
[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 @@ 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/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs
index d129949f3680..87755c68de0b 100644
--- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs
+++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs
@@ -3,26 +3,57 @@
// 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.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 +68,43 @@ 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;
+ 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 +126,124 @@ 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;
}
- 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));
+
+ 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);
- var itemCount = Items?.Count ?? 1;
- CurrentIndex = MathHelper.Modulo(CurrentIndex - 1, itemCount);
- CurrentItem = Items?.ElementAtOrDefault(CurrentIndex);
+ NavigationThrottleTimer.Start();
}
- public void AttemptNextNavigation()
+ ///
+ /// Sends the current item to the Recycle Bin.
+ ///
+ public void DeleteItem()
{
- if (NavigationThrottleTimer.IsEnabled)
+ if (CurrentItem == null || !IsFilePath(CurrentItem.Path))
{
return;
}
- NavigationThrottleTimer.Start();
+ _deletedItemIndexes.Add(_currentIndex);
+ OnPropertyChanged(nameof(DisplayItemCount));
+
+ string path = CurrentItem.Path;
+
+ DispatcherQueue.GetForCurrentThread().TryEnqueue(() =>
+ {
+ Task.Delay(DeleteDelayMs);
+ DeleteFile(path);
+ });
- var itemCount = Items?.Count ?? 1;
- CurrentIndex = MathHelper.Modulo(CurrentIndex + 1, itemCount);
- CurrentItem = Items?.ElementAtOrDefault(CurrentIndex);
+ Navigate(_navigationDirection, isAfterDelete: true);
+ }
+
+ private void DeleteFile(string path, bool permanent = false)
+ {
+ SHFILEOPSTRUCT fileOp = new()
+ {
+ wFunc = FO_DELETE,
+ pFrom = path + "\0\0",
+ fFlags = (ushort)(FOF_NOCONFIRMATION | (permanent ? 0 : FOF_ALLOWUNDO)),
+ };
+
+ int result = SHFileOperation(ref fileOp);
+
+ if (result != 0)
+ {
+ string warning = "Could not delete file. " +
+ (DeleteFileErrors.TryGetValue(result, out string? errorMessage) ? errorMessage : $"Error code {result}.");
+ Logger.LogWarning(warning);
+ }
+ }
+
+ 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 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..96acbef1718c 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_NOCONFIRMATION = 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..a8d9d98a47f6 100644
--- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml
+++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml
@@ -39,14 +39,15 @@
+ 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..9c57f263b1e5 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,8 @@
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.
+
\ No newline at end of file