/ src / modules / peek / Peek.UI / MainWindowViewModel.cs
MainWindowViewModel.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using System;
  6  using System.Collections.Generic;
  7  using System.Globalization;
  8  using System.IO;
  9  using System.Runtime.InteropServices;
 10  using System.Threading.Tasks;
 11  
 12  using CommunityToolkit.Mvvm.ComponentModel;
 13  using ManagedCommon;
 14  using Microsoft.UI.Dispatching;
 15  using Microsoft.UI.Xaml;
 16  using Peek.Common.Extensions;
 17  using Peek.Common.Helpers;
 18  using Peek.Common.Models;
 19  using Peek.UI.Helpers;
 20  using Peek.UI.Models;
 21  using Windows.Win32.Foundation;
 22  using static Peek.UI.Native.NativeMethods;
 23  
 24  namespace Peek.UI
 25  {
 26      public partial class MainWindowViewModel : ObservableObject
 27      {
 28          /// <summary>
 29          /// The minimum time in milliseconds between navigation events.
 30          /// </summary>
 31          private const int NavigationThrottleDelayMs = 100;
 32  
 33          /// <summary>
 34          /// The delay in milliseconds before a delete operation begins, to allow for navigation
 35          /// away from the current item to occur.
 36          /// </summary>
 37          private const int DeleteDelayMs = 200;
 38  
 39          /// <summary>
 40          /// Holds the indexes of each <see cref="IFileSystemItem"/> the user has deleted.
 41          /// </summary>
 42          private readonly HashSet<int> _deletedItemIndexes = [];
 43  
 44          private static readonly string _defaultWindowTitle = ResourceLoaderInstance.ResourceLoader.GetString("AppTitle/Title");
 45  
 46          /// <summary>
 47          /// The actual index of the current item in the items array. Does not necessarily
 48          /// correspond to <see cref="_displayIndex"/> if one or more files have been deleted.
 49          /// </summary>
 50          private int _currentIndex;
 51  
 52          /// <summary>
 53          /// The item index to display in the titlebar.
 54          /// </summary>
 55          [ObservableProperty]
 56          private int _displayIndex;
 57  
 58          /// <summary>
 59          /// The item to be displayed by a matching previewer. May be null if the user has deleted
 60          /// all items.
 61          /// </summary>
 62          [ObservableProperty]
 63          private IFileSystemItem? _currentItem;
 64  
 65          /// <summary>
 66          /// Work around missing navigation when peeking from CLI.
 67          /// TODO: Implement navigation when peeking from CLI.
 68          /// </summary>
 69          private bool _isFromCli;
 70  
 71          partial void OnCurrentItemChanged(IFileSystemItem? value)
 72          {
 73              WindowTitle = value != null
 74                  ? ReadableStringHelper.FormatResourceString("WindowTitle", value.Name)
 75                  : _defaultWindowTitle;
 76          }
 77  
 78          [ObservableProperty]
 79          private string _windowTitle;
 80  
 81          [ObservableProperty]
 82          [NotifyPropertyChangedFor(nameof(DisplayItemCount))]
 83          private NeighboringItems? _items;
 84  
 85          /// <summary>
 86          /// The number of items selected and available to preview. Decreases as the user deletes
 87          /// items. Displayed on the title bar.
 88          /// </summary>
 89          private int _displayItemCount;
 90  
 91          public int DisplayItemCount
 92          {
 93              get => Items?.Count - _deletedItemIndexes.Count ?? 0;
 94              set
 95              {
 96                  if (_displayItemCount != value)
 97                  {
 98                      _displayItemCount = value;
 99                      OnPropertyChanged();
100                  }
101              }
102          }
103  
104          [ObservableProperty]
105          private double _scalingFactor = 1.0;
106  
107          [ObservableProperty]
108          private string _errorMessage = string.Empty;
109  
110          [ObservableProperty]
111          private bool _isErrorVisible = false;
112  
113          private enum NavigationDirection
114          {
115              Forwards,
116              Backwards,
117          }
118  
119          /// <summary>
120          /// The current direction in which the user is moving through the items collection.
121          /// Determines how we act when a file is deleted.
122          /// </summary>
123          private NavigationDirection _navigationDirection = NavigationDirection.Forwards;
124  
125          public NeighboringItemsQuery NeighboringItemsQuery { get; }
126  
127          private DispatcherTimer NavigationThrottleTimer { get; set; } = new();
128  
129          public MainWindowViewModel(NeighboringItemsQuery query)
130          {
131              NeighboringItemsQuery = query;
132              WindowTitle = _defaultWindowTitle;
133  
134              NavigationThrottleTimer.Tick += NavigationThrottleTimer_Tick;
135              NavigationThrottleTimer.Interval = TimeSpan.FromMilliseconds(NavigationThrottleDelayMs);
136          }
137  
138          public void Initialize(SelectedItem selectedItem)
139          {
140              switch (selectedItem)
141              {
142                  case SelectedItemByPath selectedItemByPath:
143                      InitializeFromCli(selectedItemByPath.Path);
144                      break;
145  
146                  case SelectedItemByWindowHandle selectedItemByWindowHandle:
147                      InitializeFromExplorer(selectedItemByWindowHandle.WindowHandle);
148                      break;
149  
150                  default:
151                      throw new NotImplementedException($"Invalid type of selected item: '{selectedItem.GetType().FullName}'");
152              }
153          }
154  
155          private void InitializeFromExplorer(HWND foregroundWindowHandle)
156          {
157              try
158              {
159                  Items = NeighboringItemsQuery.GetNeighboringItems(foregroundWindowHandle);
160              }
161              catch (Exception ex)
162              {
163                  Logger.LogError("Failed to get File Explorer Items.", ex);
164              }
165  
166              _currentIndex = DisplayIndex = 0;
167              _isFromCli = false;
168  
169              CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null;
170          }
171  
172          private void InitializeFromCli(string path)
173          {
174              // TODO: implement navigation
175              _isFromCli = true;
176              Items = null;
177              _currentIndex = DisplayIndex = 0;
178              CurrentItem = new FileItem(path, Path.GetFileName(path));
179          }
180  
181          public void Uninitialize()
182          {
183              _currentIndex = DisplayIndex = 0;
184              CurrentItem = null;
185              _deletedItemIndexes.Clear();
186              Items = null;
187              _navigationDirection = NavigationDirection.Forwards;
188              IsErrorVisible = false;
189              _isFromCli = false;
190          }
191  
192          public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards);
193  
194          public void AttemptNextNavigation() => Navigate(NavigationDirection.Forwards);
195  
196          private void Navigate(NavigationDirection direction, bool isAfterDelete = false)
197          {
198              if (NavigationThrottleTimer.IsEnabled)
199              {
200                  return;
201              }
202  
203              // TODO: implement navigation.
204              if (_isFromCli)
205              {
206                  return;
207              }
208  
209              if (Items == null || Items.Count == _deletedItemIndexes.Count)
210              {
211                  _currentIndex = DisplayIndex = 0;
212                  CurrentItem = null;
213                  return;
214              }
215  
216              _navigationDirection = direction;
217  
218              int offset = direction == NavigationDirection.Forwards ? 1 : -1;
219  
220              do
221              {
222                  _currentIndex = MathHelper.Modulo(_currentIndex + offset, Items.Count);
223              }
224              while (_deletedItemIndexes.Contains(_currentIndex));
225  
226              CurrentItem = Items[_currentIndex];
227  
228              // If we're navigating forwards after a delete operation, the displayed index does not
229              // change, e.g. "(2/3)" becomes "(2/2)".
230              if (isAfterDelete && direction == NavigationDirection.Forwards)
231              {
232                  offset = 0;
233              }
234  
235              DisplayIndex = MathHelper.Modulo(DisplayIndex + offset, DisplayItemCount);
236  
237              NavigationThrottleTimer.Start();
238          }
239  
240          /// <summary>
241          /// Sends the current item to the Recycle Bin.
242          /// </summary>
243          /// <param name="skipConfirmationChecked">The IsChecked property of the "Don't ask me
244          /// again" checkbox on the delete confirmation dialog.</param>
245          public void DeleteItem(bool? skipConfirmationChecked, nint hwnd)
246          {
247              if (CurrentItem == null)
248              {
249                  return;
250              }
251  
252              bool skipConfirmation = skipConfirmationChecked ?? false;
253              bool shouldShowConfirmation = !skipConfirmation;
254              Application.Current.GetService<IUserSettings>().ConfirmFileDelete = shouldShowConfirmation;
255  
256              var item = CurrentItem;
257  
258              if (File.Exists(item.Path) && !IsFilePath(item.Path))
259              {
260                  // The path is to a folder, not a file, or its attributes could not be retrieved.
261                  return;
262              }
263  
264              // Update the file count and total files.
265              int index = _currentIndex;
266              _deletedItemIndexes.Add(index);
267              OnPropertyChanged(nameof(DisplayItemCount));
268  
269              // Attempt the deletion then navigate to the next file.
270              DispatcherQueue.GetForCurrentThread().TryEnqueue(async () =>
271              {
272                  await Task.Delay(DeleteDelayMs);
273                  int result = DeleteFile(item, hwnd);
274  
275                  if (result == 0)
276                  {
277                      // Success.
278                      return;
279                  }
280  
281                  if (result == ERROR_CANCELLED)
282                  {
283                      if (Path.GetPathRoot(item.Path) is string root)
284                      {
285                          var driveInfo = new DriveInfo(root);
286                          Logger.LogInfo($"User cancelled item deletion on {driveInfo.DriveType} drive.");
287                      }
288                  }
289                  else
290                  {
291                      // For failures other than user cancellation, log the error and show a message
292                      // in the UI.
293                      DeleteErrorMessageHelper.LogError(result);
294                      ShowDeleteError(item.Name, result);
295                  }
296  
297                  // For all errors, reinstate the deleted file if it still exists.
298                  ReinstateDeletedFile(item, index);
299              });
300  
301              Navigate(_navigationDirection, isAfterDelete: true);
302          }
303  
304          /// <summary>
305          /// Delete a file by moving it to the Recycle Bin. Refresh any shell listeners.
306          /// </summary>
307          /// <param name="item">The item to delete.</param>
308          /// <param name="hwnd">The handle of the main window.</param>
309          /// <returns>The result of the file operation call. A non-zero result indicates failure.
310          /// </returns>
311          private int DeleteFile(IFileSystemItem item, nint hwnd)
312          {
313              // Move to the Recycle Bin and warn about permanent deletes.
314              var flags = (ushort)(FOF_ALLOWUNDO | FOF_WANTNUKEWARNING);
315  
316              SHFILEOPSTRUCT fileOp = new()
317              {
318                  wFunc = FO_DELETE,
319                  pFrom = item.Path + "\0\0", // Path arguments must be double null-terminated.
320                  fFlags = flags,
321                  hwnd = hwnd,
322              };
323  
324              int result = SHFileOperation(ref fileOp);
325              if (result == 0)
326              {
327                  SendDeleteChangeNotification(item.Path);
328              }
329  
330              return result;
331          }
332  
333          private void ReinstateDeletedFile(IFileSystemItem item, int index)
334          {
335              if (File.Exists(item.Path))
336              {
337                  _deletedItemIndexes.Remove(index);
338                  OnPropertyChanged(nameof(DisplayItemCount));
339              }
340          }
341  
342          /// <summary>
343          /// Informs shell listeners like Explorer windows that a delete operation has occurred.
344          /// </summary>
345          /// <param name="path">Full path to the file which was deleted.</param>
346          private void SendDeleteChangeNotification(string path)
347          {
348              IntPtr pathPtr = Marshal.StringToHGlobalUni(path);
349              try
350              {
351                  if (pathPtr == IntPtr.Zero)
352                  {
353                      Logger.LogError("Could not allocate memory for path string.");
354                  }
355                  else
356                  {
357                      SHChangeNotify(SHCNE_DELETE, SHCNF_PATH, pathPtr, IntPtr.Zero);
358                  }
359              }
360              finally
361              {
362                  Marshal.FreeHGlobal(pathPtr);
363              }
364          }
365  
366          private static bool IsFilePath(string path)
367          {
368              if (string.IsNullOrEmpty(path))
369              {
370                  return false;
371              }
372  
373              try
374              {
375                  FileAttributes attributes = File.GetAttributes(path);
376                  return (attributes & FileAttributes.Directory) != FileAttributes.Directory;
377              }
378              catch (Exception)
379              {
380                  return false;
381              }
382          }
383  
384          private void ShowDeleteError(string filename, int errorCode)
385          {
386              IsErrorVisible = false;
387              ErrorMessage = DeleteErrorMessageHelper.GetUserErrorMessage(filename, errorCode);
388              IsErrorVisible = true;
389          }
390  
391          public void ShowError(string message)
392          {
393              IsErrorVisible = false;
394              ErrorMessage = message;
395              IsErrorVisible = true;
396          }
397  
398          private void NavigationThrottleTimer_Tick(object? sender, object e)
399          {
400              if (sender == null)
401              {
402                  return;
403              }
404  
405              ((DispatcherTimer)sender).Stop();
406          }
407      }
408  }