diff --git a/src/Files.App/Actions/FileSystem/Transfer/BaseTransferItemAction.cs b/src/Files.App/Actions/FileSystem/Transfer/BaseTransferItemAction.cs index a9addf150083..efef9972d726 100644 --- a/src/Files.App/Actions/FileSystem/Transfer/BaseTransferItemAction.cs +++ b/src/Files.App/Actions/FileSystem/Transfer/BaseTransferItemAction.cs @@ -1,9 +1,6 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using System.IO; using Windows.ApplicationModel.DataTransfer; using Windows.Storage; @@ -24,103 +21,7 @@ public BaseTransferItemAction() public async Task ExecuteTransferAsync(DataPackageOperation type = DataPackageOperation.Copy) { - if (ContentPageContext.ShellPage?.SlimContentPage is null || - ContentPageContext.ShellPage.SlimContentPage.IsItemSelected is false) - return; - - // Reset cut mode - ContentPageContext.ShellPage.SlimContentPage.ItemManipulationModel.RefreshItemsOpacity(); - - ConcurrentBag items = []; - var itemsCount = ContentPageContext.SelectedItems.Count; - var statusCenterItem = itemsCount > 50 ? StatusCenterHelper.AddCard_Prepare() : null; - var dataPackage = new DataPackage() { RequestedOperation = type }; - - try - { - // Update the status to in-progress - if (statusCenterItem is not null) - { - statusCenterItem.Progress.EnumerationCompleted = true; - statusCenterItem.Progress.ItemsCount = items.Count; - statusCenterItem.Progress.ReportStatus(FileSystemStatusCode.InProgress); - } - - await ContentPageContext.SelectedItems.ToList().ParallelForEachAsync(async listedItem => - { - // Update the status to increase processed count by one - if (statusCenterItem is not null) - { - statusCenterItem.Progress.AddProcessedItemsCount(1); - statusCenterItem.Progress.Report(); - } - - if (listedItem is FtpItem ftpItem) - { - // Don't dim selected items here since FTP doesn't support cut - if (ftpItem.PrimaryItemAttribute is StorageItemTypes.File or StorageItemTypes.Folder) - items.Add(await ftpItem.ToStorageItem()); - } - else - { - if (type is DataPackageOperation.Move) - { - // Dim opacities accordingly - await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => - { - listedItem.Opacity = Constants.UI.DimItemOpacity; - }); - } - - FilesystemResult? result = - listedItem.PrimaryItemAttribute == StorageItemTypes.File || listedItem is ZipItem - ? await ContentPageContext.ShellPage.ShellViewModel.GetFileFromPathAsync(listedItem.ItemPath).OnSuccess(t => items.Add(t)) - : await ContentPageContext.ShellPage.ShellViewModel.GetFolderFromPathAsync(listedItem.ItemPath).OnSuccess(t => items.Add(t)); - - if (!result) - throw new IOException($"Failed to process {listedItem.ItemPath} in cutting/copying to the clipboard.", (int)result.ErrorCode); - } - }, - 10, - statusCenterItem?.CancellationToken ?? default); - - var standardObjectsOnly = items.All(x => x is StorageFile or StorageFolder or SystemStorageFile or SystemStorageFolder); - if (standardObjectsOnly) - items = new ConcurrentBag(await items.ToStandardStorageItemsAsync()); - - if (items.IsEmpty) - return; - - dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; - dataPackage.SetStorageItems(items, false); - - Clipboard.SetContent(dataPackage); - } - catch (Exception ex) - { - dataPackage = default; - - if (ex is not IOException) - App.Logger.LogWarning(ex, "Failed to process cutting/copying due to an unknown error."); - - if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) - { - string[] filePaths = ContentPageContext.SelectedItems.Select(x => x.ItemPath).ToArray(); - await FileOperationsHelpers.SetClipboard(filePaths, type); - - return; - } - - // Reset cut mode - ContentPageContext.ShellPage.SlimContentPage.ItemManipulationModel.RefreshItemsOpacity(); - - return; - } - finally - { - if (statusCenterItem is not null) - StatusCenterViewModel.RemoveItem(statusCenterItem); - } + await TransferHelpers.ExecuteTransferAsync(ContentPageContext, StatusCenterViewModel, type); } private void ContentPageContext_PropertyChanged(object? sender, PropertyChangedEventArgs e) diff --git a/src/Files.App/Data/Items/ShelfItem.cs b/src/Files.App/Data/Items/ShelfItem.cs index 3eb42efaa63f..0daaee3a833b 100644 --- a/src/Files.App/Data/Items/ShelfItem.cs +++ b/src/Files.App/Data/Items/ShelfItem.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Files.Shared.Utils; +using System.Windows.Documents; namespace Files.App.Data.Items { @@ -34,6 +35,20 @@ public async Task InitAsync(CancellationToken cancellationToken = default) Icon = await _imageService.GetIconAsync(Inner, cancellationToken); } + [RelayCommand] + public async Task ViewInFolderAsync(CancellationToken cancellationToken) + { + var context = Ioc.Default.GetRequiredService(); + if (context.ShellPage is not { } shellPage) + return; + + var parent = await Inner.GetParentAsync(cancellationToken); + if (parent is null) + return; + + await NavigationHelpers.OpenPath(parent.Id, shellPage); + } + [RelayCommand] public void Remove() { diff --git a/src/Files.App/Helpers/TransferHelpers.cs b/src/Files.App/Helpers/TransferHelpers.cs new file mode 100644 index 000000000000..7f60fad9945b --- /dev/null +++ b/src/Files.App/Helpers/TransferHelpers.cs @@ -0,0 +1,170 @@ +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; + +namespace Files.App.Helpers +{ + public static class TransferHelpers + { + public static async Task ExecuteTransferAsync(IReadOnlyList itemsToTransfer, ShellViewModel shellViewModel, StatusCenterViewModel statusViewModel, DataPackageOperation type = DataPackageOperation.Copy) + { + ConcurrentBag items = []; + var itemsCount = itemsToTransfer.Count; + var statusCenterItem = itemsCount > 50 ? StatusCenterHelper.AddCard_Prepare() : null; + var dataPackage = new DataPackage() { RequestedOperation = type }; + + try + { + // Update the status to in-progress + if (statusCenterItem is not null) + { + statusCenterItem.Progress.EnumerationCompleted = true; + statusCenterItem.Progress.ItemsCount = items.Count; + statusCenterItem.Progress.ReportStatus(FileSystemStatusCode.InProgress); + } + + await itemsToTransfer.ParallelForEachAsync(async storable => + { + // Update the status to increase processed count by one + if (statusCenterItem is not null) + { + statusCenterItem.Progress.AddProcessedItemsCount(1); + statusCenterItem.Progress.Report(); + } + + var result = storable switch + { + IFile => await shellViewModel.GetFileFromPathAsync(storable.Id).OnSuccess(x => items.Add(x)), + IFolder => await shellViewModel.GetFolderFromPathAsync(storable.Id).OnSuccess(x => items.Add(x)), + }; + + if (!result) + throw new SystemIO.IOException($"Failed to process {storable.Id} in cutting/copying to the clipboard.", (int)result.ErrorCode); + }, 10, statusCenterItem?.CancellationToken ?? CancellationToken.None); + + var standardObjectsOnly = items.All(x => x is StorageFile or StorageFolder or SystemStorageFile or SystemStorageFolder); + if (standardObjectsOnly) + items = new(await items.ToStandardStorageItemsAsync()); + + if (items.IsEmpty) + return; + + dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; + dataPackage.SetStorageItems(items, false); + + Clipboard.SetContent(dataPackage); + } + catch (Exception ex) + { + if (ex is not SystemIO.IOException) + App.Logger.LogWarning(ex, "Failed to process cutting/copying due to an unknown error."); + + if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) + { + var filePaths = itemsToTransfer.Select(x => x.Id).ToArray(); + await FileOperationsHelpers.SetClipboard(filePaths, type); + } + } + finally + { + if (statusCenterItem is not null) + statusViewModel.RemoveItem(statusCenterItem); + } + } + + public static async Task ExecuteTransferAsync(IContentPageContext context, StatusCenterViewModel statusViewModel, DataPackageOperation type = DataPackageOperation.Copy) + { + if (context.ShellPage?.SlimContentPage is null || + context.ShellPage.SlimContentPage.IsItemSelected is false) + return; + + // Reset cut mode + context.ShellPage.SlimContentPage.ItemManipulationModel.RefreshItemsOpacity(); + + ConcurrentBag items = []; + var itemsCount = context.SelectedItems.Count; + var statusCenterItem = itemsCount > 50 ? StatusCenterHelper.AddCard_Prepare() : null; + var dataPackage = new DataPackage() { RequestedOperation = type }; + + try + { + // Update the status to in-progress + if (statusCenterItem is not null) + { + statusCenterItem.Progress.EnumerationCompleted = true; + statusCenterItem.Progress.ItemsCount = items.Count; + statusCenterItem.Progress.ReportStatus(FileSystemStatusCode.InProgress); + } + + await context.SelectedItems.ToList().ParallelForEachAsync(async listedItem => + { + // Update the status to increase processed count by one + if (statusCenterItem is not null) + { + statusCenterItem.Progress.AddProcessedItemsCount(1); + statusCenterItem.Progress.Report(); + } + + if (listedItem is FtpItem ftpItem) + { + // Don't dim selected items here since FTP doesn't support cut + if (ftpItem.PrimaryItemAttribute is StorageItemTypes.File or StorageItemTypes.Folder) + items.Add(await ftpItem.ToStorageItem()); + } + else + { + if (type is DataPackageOperation.Move) + { + // Dim opacities accordingly + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => + { + listedItem.Opacity = Constants.UI.DimItemOpacity; + }); + } + + var result = listedItem.PrimaryItemAttribute == StorageItemTypes.File || listedItem is ZipItem + ? await context.ShellPage.ShellViewModel.GetFileFromPathAsync(listedItem.ItemPath).OnSuccess(t => items.Add(t)) + : await context.ShellPage.ShellViewModel.GetFolderFromPathAsync(listedItem.ItemPath).OnSuccess(t => items.Add(t)); + + if (!result) + throw new SystemIO.IOException($"Failed to process {listedItem.ItemPath} in cutting/copying to the clipboard.", (int)result.ErrorCode); + } + }, 10, statusCenterItem?.CancellationToken ?? CancellationToken.None); + + var standardObjectsOnly = items.All(x => x is StorageFile or StorageFolder or SystemStorageFile or SystemStorageFolder); + if (standardObjectsOnly) + items = new(await items.ToStandardStorageItemsAsync()); + + if (items.IsEmpty) + return; + + dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; + dataPackage.SetStorageItems(items, false); + + Clipboard.SetContent(dataPackage); + } + catch (Exception ex) + { + if (ex is not SystemIO.IOException) + App.Logger.LogWarning(ex, "Failed to process cutting/copying due to an unknown error."); + + if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) + { + var filePaths = context.SelectedItems.Select(x => x.ItemPath).ToArray(); + await FileOperationsHelpers.SetClipboard(filePaths, type); + + return; + } + + // Reset cut mode + context.ShellPage.SlimContentPage.ItemManipulationModel.RefreshItemsOpacity(); + } + finally + { + if (statusCenterItem is not null) + statusViewModel.RemoveItem(statusCenterItem); + } + } + } +} diff --git a/src/Files.App/UserControls/Pane/ShelfPane.xaml b/src/Files.App/UserControls/Pane/ShelfPane.xaml index 121268eeaf12..fe4935830e57 100644 --- a/src/Files.App/UserControls/Pane/ShelfPane.xaml +++ b/src/Files.App/UserControls/Pane/ShelfPane.xaml @@ -3,6 +3,7 @@ x:Class="Files.App.UserControls.ShelfPane" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Files.App.Controls" xmlns:converters="using:Files.App.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:data="using:Files.App.Data.Items" @@ -152,6 +153,33 @@ + + + + + + (ICommand?)GetValue(BulkDeleteCommandProperty); + set => SetValue(BulkDeleteCommandProperty, value); + } + public static readonly DependencyProperty BulkDeleteCommandProperty = + DependencyProperty.Register(nameof(BulkDeleteCommand), typeof(ICommand), typeof(ShelfPane), new PropertyMetadata(null)); + + public ICommand? BulkCopyCommand + { + get => (ICommand?)GetValue(BulkCopyCommandProperty); + set => SetValue(BulkCopyCommandProperty, value); + } + public static readonly DependencyProperty BulkCopyCommandProperty = + DependencyProperty.Register(nameof(BulkCopyCommand), typeof(ICommand), typeof(ShelfPane), new PropertyMetadata(null)); + + public ICommand? BulkCutCommand + { + get => (ICommand?)GetValue(BulkCutCommandProperty); + set => SetValue(BulkCutCommandProperty, value); + } + public static readonly DependencyProperty BulkCutCommandProperty = + DependencyProperty.Register(nameof(BulkCutCommand), typeof(ICommand), typeof(ShelfPane), new PropertyMetadata(null)); + public ICommand? ItemFocusedCommand { get => (ICommand?)GetValue(ItemFocusedCommandProperty); @@ -135,6 +165,5 @@ public ICommand? ItemFocusedCommand } public static readonly DependencyProperty ItemFocusedCommandProperty = DependencyProperty.Register(nameof(ItemFocusedCommand), typeof(ICommand), typeof(ShelfPane), new PropertyMetadata(null)); - } } diff --git a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs index c4fa685b7bae..443b3811556a 100644 --- a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs +++ b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs @@ -227,7 +227,9 @@ public async Task DropAsync(DragEventArgs e) new MenuFlyoutItem() { Text = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), folderName), Command = new AsyncRelayCommand(async ct => await _associatedInstance.FilesystemHelpers.PerformOperationTypeAsync(DataPackageOperation.Copy, e.DataView, _associatedInstance.ShellViewModel.WorkingDirectory, false, true)) }, new MenuFlyoutItem() { Text = string.Format(Strings.MoveToFolderCaptionText.GetLocalizedResource(), folderName), Command = new AsyncRelayCommand(async ct => - await _associatedInstance.FilesystemHelpers.PerformOperationTypeAsync(DataPackageOperation.Move, e.DataView, _associatedInstance.ShellViewModel.WorkingDirectory, false, true)) } + await _associatedInstance.FilesystemHelpers.PerformOperationTypeAsync(DataPackageOperation.Move, e.DataView, _associatedInstance.ShellViewModel.WorkingDirectory, false, true)) }, + new MenuFlyoutItem() { Text = string.Format(Strings.LinkToFolderCaptionText.GetLocalizedResource(), folderName), Command = new AsyncRelayCommand(async ct => + await _associatedInstance.FilesystemHelpers.PerformOperationTypeAsync(DataPackageOperation.Link, e.DataView, _associatedInstance.ShellViewModel.WorkingDirectory, false, true)) } } }; diff --git a/src/Files.App/ViewModels/UserControls/ShelfViewModel.cs b/src/Files.App/ViewModels/UserControls/ShelfViewModel.cs index 8b80c2b61e93..832b9c5b0e07 100644 --- a/src/Files.App/ViewModels/UserControls/ShelfViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/ShelfViewModel.cs @@ -3,13 +3,19 @@ using Files.Shared.Utils; using System.Collections.Specialized; +using Windows.ApplicationModel.DataTransfer; namespace Files.App.ViewModels.UserControls { + public sealed record class WatcherReference(IFolderWatcher FolderWatcher, int ReferenceCount) + { + public int ReferenceCount { get; set; } = ReferenceCount; + } + [Bindable(true)] public sealed partial class ShelfViewModel : ObservableObject, IAsyncInitialize { - private readonly Dictionary _watchers; + private readonly Dictionary _watchers; public ObservableCollection Items { get; } @@ -33,75 +39,129 @@ private void ClearItems() Items.Clear(); } + [RelayCommand] + private async Task BulkDeleteAsync() + { + if (Items.IsEmpty()) + return; + + var context = Ioc.Default.GetRequiredService(); + if (context.ShellPage is not { } shellPage) + return; + + var itemsToDelete = Items.Select(x => StorageHelpers.FromPathAndType(x.Inner.Id, x.Inner switch + { + IFile => FilesystemItemType.File, + IFolder => FilesystemItemType.Directory, + _ => throw new ArgumentOutOfRangeException(nameof(ShelfViewModel)) + })); + + var settings = Ioc.Default.GetRequiredService(); + await shellPage.FilesystemHelpers.DeleteItemsAsync(itemsToDelete, settings.DeleteConfirmationPolicy, false, true); + await shellPage.ShellViewModel.ApplyFilesAndFoldersChangesAsync(); + } + + [RelayCommand] + private async Task BulkCopyAsync() + { + if (Items.IsEmpty()) + return; + + var context = Ioc.Default.GetRequiredService(); + var statusViewModel = Ioc.Default.GetRequiredService(); + + if (context.ShellPage?.ShellViewModel is not { } shellViewModel) + return; + + var itemsToCopy = Items.Select(x => x.Inner).ToArray(); + await TransferHelpers.ExecuteTransferAsync(itemsToCopy, shellViewModel, statusViewModel, DataPackageOperation.Copy); + } + + [RelayCommand] + private async Task BulkCutAsync() + { + if (Items.IsEmpty()) + return; + + var context = Ioc.Default.GetRequiredService(); + var statusViewModel = Ioc.Default.GetRequiredService(); + + if (context.ShellPage?.ShellViewModel is not { } shellViewModel) + return; + + var itemsToCut = Items.Select(x => x.Inner).ToArray(); + await TransferHelpers.ExecuteTransferAsync(itemsToCut, shellViewModel, statusViewModel, DataPackageOperation.Move); + } + private async void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add when e.NewItems is not null: + { + if (e.NewItems[0] is not ShelfItem shelfItem) + return; + + var parentPath = SystemIO.Path.GetDirectoryName(shelfItem.Inner.Id) ?? string.Empty; + if (_watchers.TryGetValue(parentPath, out var reference)) { - if (e.NewItems[0] is not ShelfItem shelfItem) - return; - - var parentPath = SystemIO.Path.GetDirectoryName(shelfItem.Inner.Id) ?? string.Empty; - if (_watchers.TryGetValue(parentPath, out var reference)) - { - // Only increase the reference count if the watcher already exists - reference.Item2++; - return; - } - - if (await shelfItem.Inner.GetParentAsync() is not IMutableFolder mutableFolder) - return; - - // Register new watcher - var watcher = await mutableFolder.GetFolderWatcherAsync(); - watcher.CollectionChanged += Watcher_CollectionChanged; - - _watchers.Add(parentPath, (watcher, 1)); - break; + // Only increase the reference count if the watcher already exists + reference.ReferenceCount += 1; + return; } + + if (await shelfItem.Inner.GetParentAsync() is not IMutableFolder mutableFolder) + return; + + // Register new watcher + var watcher = await mutableFolder.GetFolderWatcherAsync(); + watcher.CollectionChanged += Watcher_CollectionChanged; + + _watchers.Add(parentPath, new(watcher, 1)); + break; + } case NotifyCollectionChangedAction.Remove when e.OldItems is not null: + { + if (e.OldItems[0] is not ShelfItem shelfItem) + return; + + var parentPath = SystemIO.Path.GetDirectoryName(shelfItem.Inner.Id) ?? string.Empty; + if (!_watchers.TryGetValue(parentPath, out var reference)) + return; + + // Decrease the reference count and remove the watcher if no references are present + reference.ReferenceCount -= 1; + if (reference.ReferenceCount < 1) { - if (e.OldItems[0] is not ShelfItem shelfItem) - return; - - var parentPath = SystemIO.Path.GetDirectoryName(shelfItem.Inner.Id) ?? string.Empty; - if (!_watchers.TryGetValue(parentPath, out var reference)) - return; - - // Decrease the reference count and remove the watcher if no references are present - reference.Item2--; - if (reference.Item2 < 1) - { - reference.Item1.CollectionChanged -= Watcher_CollectionChanged; - reference.Item1.Dispose(); - _watchers.Remove(parentPath); - } - - break; + reference.FolderWatcher.CollectionChanged -= Watcher_CollectionChanged; + reference.FolderWatcher.Dispose(); + _watchers.Remove(parentPath); } + + break; + } } } private async void Watcher_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - if (sender is not IFolderWatcher watcher) + if (sender is not IFolderWatcher) return; switch (e.Action) { case NotifyCollectionChangedAction.Remove when e.OldItems is not null: - { - // Remove the matching item notified from the watcher - var item = e.OldItems.Cast().ElementAt(0); - var itemToRemove = Items.FirstOrDefault(x => x.Inner.Id == item.Id); - if (itemToRemove is null) - return; - - await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => Items.Remove(itemToRemove)); - break; - } + { + // Remove the matching item notified from the watcher + var item = e.OldItems.Cast().ElementAt(0); + var itemToRemove = Items.FirstOrDefault(x => x.Inner.Id == item.Id); + if (itemToRemove is null) + return; + + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => Items.Remove(itemToRemove)); + break; + } } } } diff --git a/src/Files.App/Views/MainPage.xaml b/src/Files.App/Views/MainPage.xaml index 73587546e8e5..141738bf980e 100644 --- a/src/Files.App/Views/MainPage.xaml +++ b/src/Files.App/Views/MainPage.xaml @@ -271,8 +271,11 @@ Grid.Column="3" Margin="4,0,0,8" x:Load="{x:Bind ViewModel.ShowShelfPane, Mode=OneWay}" + BulkCopyCommand="{x:Bind ViewModel.ShelfViewModel.BulkCopyCommand}" + BulkCutCommand="{x:Bind ViewModel.ShelfViewModel.BulkCutCommand}" + BulkDeleteCommand="{x:Bind ViewModel.ShelfViewModel.BulkDeleteCommand}" ClearCommand="{x:Bind ViewModel.ShelfViewModel.ClearItemsCommand}" - ItemFocusedCommand="{x:Bind Commands.ClearSelection, Mode=OneWay}" + ItemFocusedCommand="{x:Bind Commands.ClearSelection}" ItemsSource="{x:Bind ViewModel.ShelfViewModel.Items}" />