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 }