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