diff --git a/src/Files.App/Data/Contracts/IDevToolsSettingsService.cs b/src/Files.App/Data/Contracts/IDevToolsSettingsService.cs index b942dec691cb..c630cd62e15f 100644 --- a/src/Files.App/Data/Contracts/IDevToolsSettingsService.cs +++ b/src/Files.App/Data/Contracts/IDevToolsSettingsService.cs @@ -19,5 +19,15 @@ public interface IDevToolsSettingsService : IBaseSettingsService, INotifyPropert /// Gets or sets the name of the chosen IDE. /// string IDEName { get; set; } + + /// + /// Gets or sets a value indicating whether to use Robocopy for file operations. + /// + bool UseRobocopyForFileOperations { get; set; } + + /// + /// Gets or sets the number of threads to use with Robocopy. + /// + int RobocopyThreads { get; set; } } } diff --git a/src/Files.App/Data/Contracts/IUserSettingsService.cs b/src/Files.App/Data/Contracts/IUserSettingsService.cs index 71e3f67b7394..380537f92a5c 100644 --- a/src/Files.App/Data/Contracts/IUserSettingsService.cs +++ b/src/Files.App/Data/Contracts/IUserSettingsService.cs @@ -24,5 +24,7 @@ public interface IUserSettingsService : IBaseSettingsService ILayoutSettingsService LayoutSettingsService { get; } IAppSettingsService AppSettingsService { get; } + + IDevToolsSettingsService DevToolsSettingsService { get; } } } diff --git a/src/Files.App/Data/Contracts/IWindowsJumpListService.cs b/src/Files.App/Data/Contracts/IWindowsJumpListService.cs index df4d8aa0ffb5..74ce35b63345 100644 --- a/src/Files.App/Data/Contracts/IWindowsJumpListService.cs +++ b/src/Files.App/Data/Contracts/IWindowsJumpListService.cs @@ -13,6 +13,11 @@ public interface IWindowsJumpListService Task RemoveFolderAsync(string path); + /// + /// Removes multiple folders using a single Jump List update. + /// + Task RemoveFoldersAsync(IEnumerable paths); + Task> GetFoldersAsync(); } } diff --git a/src/Files.App/Data/Enums/CopyEngineResult.cs b/src/Files.App/Data/Enums/CopyEngineResult.cs index 42cad448a6a7..6fa5251bc305 100644 --- a/src/Files.App/Data/Enums/CopyEngineResult.cs +++ b/src/Files.App/Data/Enums/CopyEngineResult.cs @@ -63,6 +63,7 @@ public static FileSystemStatusCode Convert(int? hres) { CopyEngineResult.S_OK => FileSystemStatusCode.Success, CopyEngineResult.COPYENGINE_E_ACCESS_DENIED_SRC => FileSystemStatusCode.Unauthorized, + CopyEngineResult.COPYENGINE_E_USER_CANCELLED => FileSystemStatusCode.Generic, CopyEngineResult.COPYENGINE_E_ACCESS_DENIED_DEST => FileSystemStatusCode.Unauthorized, CopyEngineResult.COPYENGINE_E_REQUIRES_ELEVATION => FileSystemStatusCode.Unauthorized, CopyEngineResult.COPYENGINE_E_RECYCLE_PATH_TOO_LONG => FileSystemStatusCode.NameTooLong, diff --git a/src/Files.App/Services/Settings/DevToolsSettingsService.cs b/src/Files.App/Services/Settings/DevToolsSettingsService.cs index 883c4e4e489e..474fcc1968ed 100644 --- a/src/Files.App/Services/Settings/DevToolsSettingsService.cs +++ b/src/Files.App/Services/Settings/DevToolsSettingsService.cs @@ -32,6 +32,20 @@ public string IDEName set => Set(value); } + /// + public bool UseRobocopyForFileOperations + { + get => Get(false); + set => Set(value); + } + + /// + public int RobocopyThreads + { + get => Get(8); + set => Set(value); + } + protected override void RaiseOnSettingChangedEvent(object sender, SettingChangedEventArgs e) { base.RaiseOnSettingChangedEvent(sender, e); diff --git a/src/Files.App/Services/Settings/UserSettingsService.cs b/src/Files.App/Services/Settings/UserSettingsService.cs index 89c79757ee01..c17cb96b923e 100644 --- a/src/Files.App/Services/Settings/UserSettingsService.cs +++ b/src/Files.App/Services/Settings/UserSettingsService.cs @@ -51,6 +51,12 @@ public IAppSettingsService AppSettingsService get => GetSettingsService(ref _AppSettingsService); } + private IDevToolsSettingsService _DevToolsSettingsService; + public IDevToolsSettingsService DevToolsSettingsService + { + get => GetSettingsService(ref _DevToolsSettingsService); + } + public UserSettingsService() { SettingsSerializer = new DefaultSettingsSerializer(); diff --git a/src/Files.App/Services/Windows/WindowsJumpListService.cs b/src/Files.App/Services/Windows/WindowsJumpListService.cs index 572718c619d1..7fb9eb3c2c3c 100644 --- a/src/Files.App/Services/Windows/WindowsJumpListService.cs +++ b/src/Files.App/Services/Windows/WindowsJumpListService.cs @@ -109,19 +109,37 @@ public async Task RefreshPinnedFoldersAsync() public async Task RemoveFolderAsync(string path) { - if (JumpList.IsSupported()) + await RemoveFoldersAsync([path]); + } + + /// + public async Task RemoveFoldersAsync(IEnumerable paths) + { + if (!JumpList.IsSupported()) + return; + + try { - try - { - var instance = await JumpList.LoadCurrentAsync(); - // Disable automatic jumplist. It doesn't work. - instance.SystemGroupKind = JumpListSystemGroupKind.None; + var pathsToRemove = paths.ToHashSet(StringComparer.OrdinalIgnoreCase); + if (pathsToRemove.Count == 0) + return; + + var instance = await JumpList.LoadCurrentAsync(); + // Disable automatic jumplist. It doesn't work. + instance.SystemGroupKind = JumpListSystemGroupKind.None; - var itemToRemove = instance.Items.Where(x => x.Arguments == path).Select(x => x).FirstOrDefault(); - instance.Items.Remove(itemToRemove); + var itemsToRemove = instance.Items + .Where(item => pathsToRemove.Contains(item.Arguments)) + .ToArray(); + foreach (var item in itemsToRemove) + instance.Items.Remove(item); + + if (itemsToRemove.Length > 0) await instance.SaveAsync(); - } - catch { } + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, ex.Message); } } diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index 37f75e59afcb..ca51d6c0acae 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -2177,6 +2177,15 @@ Show flatten options + + Use Robocopy for file operations + + + Use multi-threaded Robocopy engine for large copy, move, and delete operations + + + Threads + Select files and folders when hovering over them diff --git a/src/Files.App/Utils/StatusCenter/StatusCenterItem.cs b/src/Files.App/Utils/StatusCenter/StatusCenterItem.cs index b1e063b890f3..4cab3046b430 100644 --- a/src/Files.App/Utils/StatusCenter/StatusCenterItem.cs +++ b/src/Files.App/Utils/StatusCenter/StatusCenterItem.cs @@ -149,9 +149,9 @@ public StatusCenterItemProgressModel Progress public bool IsDiscovering { get; private set; } = true; - public IEnumerable? Source { get; private set; } + public string[]? Source { get; private set; } - public IEnumerable? Destination { get; private set; } + public string[]? Destination { get; private set; } public string? HeaderStringResource { get; private set; } @@ -202,8 +202,10 @@ public StatusCenterItem( SpeedGraphValues = []; CancelCommand = new RelayCommand(ExecuteCancelCommand); Message = Strings.DiscoveringItems.GetLocalizedResource(); - Source = source; - Destination = destination; + // Status text only uses the first path. Retaining every path keeps large completed + // operations and their source storage objects alive for the lifetime of the card. + Source = source?.Take(1).ToArray(); + Destination = destination?.Take(1).ToArray(); // Get the graph color if (App.Current.Resources["App.Theme.FillColorAttentionBrush"] is not SolidColorBrush accentBrush) diff --git a/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs index e578783c305e..054b32238dc6 100644 --- a/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs +++ b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs @@ -18,7 +18,10 @@ public sealed partial class FileOperationsHelpers private static readonly Ole32.PROPERTYKEY PKEY_FilePlaceholderStatus = new Ole32.PROPERTYKEY(new Guid("B2F9B9D6-FEC4-4DD5-94D7-8957488C807B"), 2); private const uint PS_CLOUDFILE_PLACEHOLDER = 8; + private static readonly IDevToolsSettingsService DevToolsSettingsService = Ioc.Default.GetRequiredService(); + private static ProgressHandler? progressHandler; // Warning: must be initialized from a MTA thread + private static readonly ConcurrentDictionary robocopyOperationTokens = new(); public static Task SetClipboard(string[] filesToCopy, DataPackageOperation operation) { @@ -676,8 +679,746 @@ public static Task SetClipboard(string[] filesToCopy, DataPackageOperation opera }, App.Logger); } + public static Task<(bool, ShellOperationResult)> CopyItemWithRobocopyAsync(string[] fileToCopyPath, string[] copyDestination, bool overwriteOnCopy, long ownerHwnd, bool asAdmin, IProgress progress, string operationID = "", IShellPage? shellPage = null) + { + return PerformRobocopyOperationAsync( + fileToCopyPath, + copyDestination, + overwriteOnCopy, + progress, + operationID, + shellPage, + isMoveOperation: false); + } + + public static Task<(bool, ShellOperationResult)> MoveItemWithRobocopyAsync(string[] fileToMovePath, string[] moveDestination, bool overwriteOnMove, long ownerHwnd, bool asAdmin, IProgress progress, string operationID = "", IShellPage? shellPage = null) + { + return PerformRobocopyOperationAsync( + fileToMovePath, + moveDestination, + overwriteOnMove, + progress, + operationID, + shellPage, + isMoveOperation: true); + } + + public static Task<(bool, ShellOperationResult)> DeleteItemWithRobocopyAsync(string[] fileToDeletePath, long ownerHwnd, bool asAdmin, IProgress progress, string operationID = "", IShellPage? shellPage = null) + { + return PerformRobocopyDeleteOperationAsync( + fileToDeletePath, + progress, + operationID, + shellPage); + } + + private static async Task<(bool success, int hResult)> RunRobocopyAsync(string arguments, StatusCenterItemProgressModel? progressModel, IReadOnlyCollection? expectedItemNames, string operationID, CancellationToken cancellationToken) + { + try + { + App.Logger?.LogInformation($"Robocopy operation {operationID}: Starting with arguments: {arguments}"); + + // Robocopy writes output using the system OEM code page, not UTF-8. + var oemEncoding = System.Text.Encoding.GetEncoding( + System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); + + var psi = new ProcessStartInfo + { + FileName = "robocopy.exe", + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardOutputEncoding = oemEncoding, + StandardErrorEncoding = oemEncoding + }; + + using var process = new Process { StartInfo = psi }; + var outputCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var remainingItemNames = expectedItemNames is null + ? null + : new HashSet(expectedItemNames, StringComparer.OrdinalIgnoreCase); + var initialProcessedSize = progressModel?.ProcessedSize ?? 0; + long batchProcessedSize = 0; + var hResult = -1; + process.OutputDataReceived += (_, e) => + { + if (e.Data is null) + { + outputCompleted.TrySetResult(); + return; + } + + if (e.Data.Contains("(0x00000020)", StringComparison.OrdinalIgnoreCase)) + hResult = CopyEngineResult.HRESULT_ERROR_SHARING_VIOLATION; + + var fields = e.Data.Split('\t', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var completedItemName = fields.Length > 0 ? Path.GetFileName(fields[^1]) : string.Empty; + var itemSize = 0L; + var hasItemSize = fields.Length > 1 && long.TryParse(fields[^2], out itemSize); + var isCompletedItem = remainingItemNames is null + ? hasItemSize + : remainingItemNames.Remove(completedItemName); + if (progressModel is not null && isCompletedItem) + { + if (hasItemSize) + { + var processedSize = initialProcessedSize + Interlocked.Add(ref batchProcessedSize, itemSize); + progressModel.SetProcessedSize(processedSize); + } + + progressModel.FileName = completedItemName; + progressModel.AddProcessedItemsCount(1); + var percentage = progressModel.TotalSize > 0 && progressModel.ProcessedSize > 0 + ? Math.Min(99, progressModel.ProcessedSize * 100.0 / progressModel.TotalSize) + : Math.Min(99, progressModel.ProcessedItemsCount * 100.0 / Math.Max(1, progressModel.ItemsCount)); + progressModel.Report(percentage); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + + var errorTask = process.StandardError.ReadToEndAsync(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMinutes(30)); + using var registration = timeoutCts.Token.Register(() => + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + } + }); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + } + + try + { + await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + await Task.WhenAll(outputCompleted.Task, errorTask).WaitAsync(TimeSpan.FromSeconds(10)); + } + catch + { + // Do not let a process that resisted termination block later operations. + } + + if (cancellationToken.IsCancellationRequested) + { + App.Logger?.LogWarning($"Robocopy operation {operationID}: Cancelled"); + return (false, -3); + } + + App.Logger?.LogWarning($"Robocopy operation {operationID}: Timed out"); + return (false, -2); + } + + await Task.WhenAll(outputCompleted.Task, errorTask); + + var exitCode = process.ExitCode; + // Bit 2 means mismatched files; treating it as success can hide a partial move. + var success = exitCode is >= 0 and <= 3; + if (!success) + { + var error = await errorTask; + App.Logger?.LogWarning($"Robocopy operation {operationID}: Exit code {exitCode}. {error}"); + } + else + { + App.Logger?.LogInformation($"Robocopy operation {operationID}: Completed with exit code {exitCode}"); + } + + return (success, success ? 0 : hResult); + } + catch (Exception ex) + { + App.Logger?.LogError(ex, $"Robocopy operation {operationID}: Failed with exception"); + return (false, -1); + } + } + + private static (Dictionary<(string sourceDir, string destDir), List> fileGroups, List<(string sourcePath, string destPath)> folderItems) GroupFilesAndFolders( + string[] filePaths, + string[] destinationPaths) + { + var fileGroups = new Dictionary<(string sourceDir, string destDir), List>(); + var folderItems = new List<(string sourcePath, string destPath)>(); + + for (var i = 0; i < filePaths.Length; i++) + { + var sourcePath = filePaths[i]; + var destPath = destinationPaths[i]; + var isDirectory = Win32Helper.HasFileAttribute(sourcePath, FileAttributes.Directory); + + if (isDirectory) + { + // For directories: store full source and destination paths for individual processing + folderItems.Add((sourcePath, destPath)); + } + else + { + // For files: group by sourceDir/destDir for batching + var sourceDir = Path.GetDirectoryName(sourcePath)!; + var itemName = Path.GetFileName(sourcePath); + var destDir = Path.GetDirectoryName(destPath)!; + + var key = (sourceDir, destDir); + if (!fileGroups.TryGetValue(key, out var list)) + { + list = new List(); + fileGroups[key] = list; + } + list.Add(itemName); + } + } + return (fileGroups, folderItems); + } + + private static (Dictionary<(string sourceDir, string destDir), List>> batchesByGroup, int totalBatches) CreateBatchesForFileGroups( + Dictionary<(string sourceDir, string destDir), List> fileGroups) + { + var batchesByGroup = new Dictionary<(string sourceDir, string destDir), List>>(); + var totalBatches = 0; + + foreach (var group in fileGroups) + { + var groupBatches = new List>(); + var currentBatch = new List(); + int currentBatchSize = 0; + const int maxBatchSize = 8000; + + foreach (var itemName in group.Value) + { + // Calculate the size this item would add to the batch + // Include quotes if the item name contains spaces, plus space separator + int itemSize = itemName.Contains(' ') ? + itemName.Length + 2 + 1 : // +2 for quotes, +1 for space + itemName.Length + 1; // +1 for space + + // If adding this item would exceed the batch size limit, start a new batch + if (currentBatch.Count > 0 && currentBatchSize + itemSize > maxBatchSize) + { + groupBatches.Add(currentBatch); + currentBatch = new List(); + currentBatchSize = 0; + totalBatches++; + } + + // Add the item to the current batch + currentBatch.Add(itemName); + currentBatchSize += itemSize; + } + + // Add the final batch for this group if it has items + if (currentBatch.Count > 0) + { + groupBatches.Add(currentBatch); + totalBatches++; + } + + batchesByGroup[group.Key] = groupBatches; + } + + return (batchesByGroup, totalBatches); + } + + private static Task<(bool, ShellOperationResult)> PerformRobocopyOperationAsync( + string[] filePaths, + string[] destinationPaths, + bool overwriteOnOperation, + IProgress progress, + string operationID, + IShellPage? shellPage, + bool isMoveOperation) + { + operationID = string.IsNullOrEmpty(operationID) ? Guid.NewGuid().ToString() : operationID; + + StatusCenterItemProgressModel fsProgress = new( + progress, + false, + FileSystemStatusCode.InProgress); + + CancellationTokenSource cts = new(); + robocopyOperationTokens.TryGetValue(operationID, out var previousCts); + robocopyOperationTokens[operationID] = cts; + + var sizeCalculator = new FileSizeCalculator(filePaths); + var sizeTask = sizeCalculator.ComputeSizeAsync(cts.Token); + _ = sizeTask.ContinueWith(task => + { + if (!task.IsCompletedSuccessfully) + return; + + fsProgress.TotalSize = sizeCalculator.Size; + fsProgress.ItemsCount = sizeCalculator.ItemsCount; + fsProgress.EnumerationCompleted = true; + fsProgress.Report(); + }, TaskScheduler.Default); + + fsProgress.ItemsCount = filePaths.Length; + fsProgress.Report(); + progressHandler ??= new(); + + return Task.Run(async () => + { + var shellOperationResult = new ShellOperationResult(); + var success = true; + + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Processing {filePaths.Length} items"); + + // Initial progress update + fsProgress.Report(0); + + try + { + progressHandler.AddOperation(operationID); + + // Group files and folders separately + var (fileGroups, folderItems) = GroupFilesAndFolders(filePaths, destinationPaths); + + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Created {fileGroups.Count} file groups and {folderItems.Count} folder items"); + + var threads = Math.Clamp(DevToolsSettingsService.RobocopyThreads, 1, 128); + + // Create batches for files only (folders will be processed individually) + (Dictionary<(string sourceDir, string destDir), List>> fileBatchesByGroup, int totalFileBatches) = CreateBatchesForFileGroups(fileGroups); + + var totalOperations = totalFileBatches + folderItems.Count; + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Created {fileBatchesByGroup.Sum(g => g.Value.Count)} file batches and {folderItems.Count} folder operations (total: {totalOperations})"); + + // Execute file batches per source/destination directory combo (8000 chars max) + var completed = 0; + foreach (var groupKvp in fileBatchesByGroup) + { + if (cts.Token.IsCancellationRequested || progressHandler.CheckCanceled(operationID)) + { + success = false; + cts.Cancel(); + break; + } + + (string sourceDir, string destDir) = groupKvp.Key; + var groupBatches = groupKvp.Value; + + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Processing file group ({sourceDir}, {destDir}) with {groupBatches.Count} batches"); + + foreach (var itemNames in groupBatches) + { + if (cts.Token.IsCancellationRequested || progressHandler.CheckCanceled(operationID)) + { + success = false; + cts.Cancel(); + break; + } + + var batchOk = true; + var hResult = 0; + + var argsList = new List + { + $"\"{sourceDir}\"", + $"\"{destDir}\"", + string.Join(" ", itemNames.Select(name => + name.Contains(' ') ? $"\"{name}\"" : name)), + "/R:3", + "/W:1", + "/NJH", + "/NJS", + "/NDL", + "/NP", + "/BYTES", + $"/MT:{threads}" + }; + + if (!overwriteOnOperation) + { + argsList.Add("/XN"); + argsList.Add("/XO"); + argsList.Add("/XC"); + } + else + { + // A move with replace semantics must process files Robocopy considers unchanged. + argsList.Add("/IS"); + argsList.Add("/IT"); + } + + // Add operation-specific flags + if (isMoveOperation) + argsList.Add("/MOV"); + + var robocopyArgs = string.Join(" ", argsList); + + // check if the argsList is longer than 8000 characters + if (robocopyArgs.Length > 8000) + { + App.Logger?.LogWarning($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Args list is longer than 8000 characters, trying anyway"); + } + + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Executing file batch with {itemNames.Count} items, args length: {robocopyArgs.Length}"); + (batchOk, hResult) = await RunRobocopyAsync(robocopyArgs, fsProgress, itemNames, operationID, cts.Token); + + // Robocopy exit codes describe the batch, so verify every requested item before + // reporting success. A skipped move otherwise looks successful while its source remains. + var batchVerified = true; + foreach (var itemName in itemNames) + { + var sourcePath = Path.Combine(sourceDir, itemName); + var destinationPath = Path.Combine(destDir, itemName); + var itemOk = batchOk && StorageHelpers.Exists(destinationPath) && + (!isMoveOperation || !StorageHelpers.Exists(sourcePath)); + batchVerified &= itemOk; + shellOperationResult.Items.Add(new ShellOperationItemResult + { + Succeeded = itemOk, + Source = sourcePath, + Destination = destinationPath, + HResult = itemOk ? 0 : hResult != 0 ? hResult : -1 + }); + } + batchOk &= batchVerified; + + if (!batchOk) + { + App.Logger?.LogWarning($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: File batch failed with HRESULT {hResult}"); + success = false; + } + else + { + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: File batch completed successfully"); + } + + completed++; + fsProgress.Report(); + + } + } + + // Process folders individually + foreach (var (sourcePath, destPath) in folderItems) + { + if (cts.Token.IsCancellationRequested || progressHandler.CheckCanceled(operationID)) + { + success = false; + cts.Cancel(); + break; + } + + var folderOk = true; + var hResult = 0; + + var argsList = new List + { + $"\"{sourcePath}\"", + $"\"{destPath}\"", + "/E", + "/R:3", + "/W:1", + "/NJH", + "/NJS", + "/NDL", + "/NP", + "/BYTES", + $"/MT:{threads}" + }; + + if (!overwriteOnOperation) + { + argsList.Add("/XN"); + argsList.Add("/XO"); + argsList.Add("/XC"); + } + else + { + // A move with replace semantics must process files Robocopy considers unchanged. + argsList.Add("/IS"); + argsList.Add("/IT"); + } + + // Add operation-specific flags + if (isMoveOperation) + argsList.Add("/MOVE"); + + var robocopyArgs = string.Join(" ", argsList); + + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Processing folder {sourcePath} -> {destPath}"); + (folderOk, hResult) = await RunRobocopyAsync(robocopyArgs, fsProgress, null, operationID, cts.Token); + + folderOk = folderOk && StorageHelpers.Exists(destPath) && + (!isMoveOperation || !StorageHelpers.Exists(sourcePath)); + shellOperationResult.Items.Add(new ShellOperationItemResult + { + Succeeded = folderOk, + Source = sourcePath, + Destination = destPath, + HResult = folderOk ? 0 : hResult != 0 ? hResult : -1 + }); + + if (!folderOk) + { + App.Logger?.LogWarning($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Folder operation failed with HRESULT {hResult}"); + success = false; + } + else + { + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Folder operation completed successfully"); + } + + completed++; + fsProgress.Report(); + + } + + if (success) + fsProgress.Report(100); + + if (shellPage is not null) + { + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => + shellPage.ShellViewModel.RefreshItems(null)); + } + + App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Completed with overall success: {success}"); + } + catch (Exception ex) + { + App.Logger?.LogError(ex, $"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Failed with exception"); + success = false; + } + finally + { + progressHandler.RemoveOperation(operationID); + if (robocopyOperationTokens.TryGetValue(operationID, out var trackedCts) + && ReferenceEquals(trackedCts, cts)) + { + if (previousCts is not null) + robocopyOperationTokens[operationID] = previousCts; + else + robocopyOperationTokens.TryRemove(operationID, out _); + } + cts.Cancel(); + try + { + await sizeTask.WaitAsync(TimeSpan.FromSeconds(2)); + } + catch (OperationCanceledException) + { + } + catch (TimeoutException) + { + } + cts.Dispose(); + } + + return (success, shellOperationResult); + }); + } + + private static string GetUniqueTempDeleteName(string baseName, HashSet usedNames) + { + var name = baseName; + var stem = Path.GetFileNameWithoutExtension(baseName); + var ext = Path.GetExtension(baseName); + var i = 1; + while (!usedNames.Add(name)) + name = $"{stem} ({i++}){ext}"; + return name; + } + + private static Task<(bool, ShellOperationResult)> PerformRobocopyDeleteOperationAsync( + string[] filePaths, + IProgress progress, + string operationID, + IShellPage? shellPage) + { + operationID = string.IsNullOrEmpty(operationID) ? Guid.NewGuid().ToString() : operationID; + + StatusCenterItemProgressModel fsProgress = new( + progress, + false, + FileSystemStatusCode.InProgress); + + CancellationTokenSource cts = new(); + robocopyOperationTokens.TryGetValue(operationID, out var previousCts); + robocopyOperationTokens[operationID] = cts; + + var sizeCalculator = new FileSizeCalculator(filePaths); + var sizeTask = sizeCalculator.ComputeSizeAsync(cts.Token); + sizeTask.ContinueWith(_ => + { + fsProgress.TotalSize = sizeCalculator.Size; + fsProgress.ItemsCount = filePaths.Length; + fsProgress.EnumerationCompleted = true; + fsProgress.Report(); + }); + + fsProgress.Report(); + progressHandler ??= new(); + + return STATask.Run(async () => + { + var shellOperationResult = new ShellOperationResult(); + var success = true; + + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: Processing {filePaths.Length} items for deletion"); + + // Initial progress update + fsProgress.Report(0); + + try + { + progressHandler.AddOperation(operationID); + + // Step 1: Create temp folder structure + var tempBasePath = Path.GetTempPath(); + var tempDeleteFolder = Path.Combine(tempBasePath, $"Files_Delete_{Guid.NewGuid()}"); + var emptyFolder = Path.Combine(tempBasePath, $"Files_Empty_{Guid.NewGuid()}"); + + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: Created temp folders - Delete: {tempDeleteFolder}, Empty: {emptyFolder}"); + + // Create temp directories + Directory.CreateDirectory(tempDeleteFolder); + Directory.CreateDirectory(emptyFolder); + + var threads = Math.Clamp(DevToolsSettingsService.RobocopyThreads, 1, 128); + + // Step 2: Move files to temp folder first (reuse existing move logic) + var tempDestinations = new string[filePaths.Length]; + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < filePaths.Length; i++) + { + var fileName = Path.GetFileName(filePaths[i]); + var uniqueName = GetUniqueTempDeleteName(fileName, usedNames); + tempDestinations[i] = Path.Combine(tempDeleteFolder, uniqueName); + } + + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: Starting move to temp folder"); + + // Use existing move functionality to move files to temp + var (moveSuccess, moveResult) = await PerformRobocopyOperationAsync( + filePaths, + tempDestinations, + true, // overwrite + new Progress(p => fsProgress.Report(p.Percentage / 2)), // Half progress for move + operationID, + shellPage, + isMoveOperation: true); + + shellOperationResult.Items.AddRange(moveResult.Final.Where(x => !x.Succeeded)); + + if (!moveSuccess) + { + App.Logger?.LogWarning($"Robocopy delete operation {operationID}: Move to temp folder failed"); + success = false; + } + else + { + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: Move to temp folder completed successfully"); + fsProgress.Report(50); // 50% complete after move + + // Step 3: Use robocopy MIR to delete all data (single command, no batching) + var robocopyArgs = $"\"{emptyFolder}\" \"{tempDeleteFolder}\" /MIR /MT:{threads} /R:0 /W:0 /NJH /NJS /NP"; + + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: Starting MIR deletion with args: {robocopyArgs}"); + + var (deleteSuccess, hResult) = await RunRobocopyAsync(robocopyArgs, null, null, operationID, cts.Token); + + if (!deleteSuccess) + { + App.Logger?.LogWarning($"Robocopy delete operation {operationID}: MIR deletion failed with HRESULT {hResult}"); + success = false; + } + else + { + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: MIR deletion completed successfully"); + } + + fsProgress.Report(100); // 100% complete after robocopy MIR + } + + // Step 4: Clean up temp folders + try + { + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: Cleaning up temp folders"); + if (Directory.Exists(tempDeleteFolder)) + Directory.Delete(tempDeleteFolder, true); + if (Directory.Exists(emptyFolder)) + Directory.Delete(emptyFolder, true); + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: Temp folder cleanup completed"); + } + catch (Exception ex) + { + App.Logger?.LogWarning(ex, $"Robocopy delete operation {operationID}: Temp folder cleanup failed"); + // Ignore cleanup errors + } + + fsProgress.Report(100); // 100% complete + + App.Logger?.LogInformation($"Robocopy delete operation {operationID}: Completed with overall success: {success}"); + + // Refresh UI if shellPage is provided + if (shellPage is not null) + { + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => + shellPage.ShellViewModel.RefreshItems(null)); + } + + } + catch (Exception ex) + { + App.Logger?.LogError(ex, $"Robocopy delete operation {operationID}: Failed with exception"); + success = false; + } + finally + { + progressHandler.RemoveOperation(operationID); + if (robocopyOperationTokens.TryGetValue(operationID, out var trackedCts) + && ReferenceEquals(trackedCts, cts)) + { + if (previousCts is not null) + robocopyOperationTokens[operationID] = previousCts; + else + robocopyOperationTokens.TryRemove(operationID, out _); + } + cts.Cancel(); + cts.Dispose(); + } + + return (success, shellOperationResult); + }, App.Logger); + } + public static void TryCancelOperation(string operationId) - => progressHandler?.TryCancel(operationId); + { + progressHandler?.TryCancel(operationId); + if (robocopyOperationTokens.TryGetValue(operationId, out var cts)) + { + try + { + cts.Cancel(); + } + catch + { + } + } + } public static IEnumerable? CheckFileInUse(string[] fileToCheckPath) { diff --git a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs index e930860a5155..9ddedc055a7f 100644 --- a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs +++ b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs @@ -161,9 +161,10 @@ showDialog is DeleteConfirmationPolicies.PermanentOnly && if (!permanently && registerHistory) App.HistoryWrapper.AddHistory(history); - // Execute removal tasks concurrently in background - var sourcePaths = source.Select(x => x.Path); - _ = Task.WhenAll(sourcePaths.Select(jumpListService.RemoveFolderAsync)); + var deletedFolderPaths = source + .Where(item => item.ItemType == FilesystemItemType.Directory) + .Select(item => item.Path); + await jumpListService.RemoveFoldersAsync(deletedFolderPaths); var itemsCount = banner.TotalItemsCount; @@ -476,9 +477,11 @@ public async Task MoveItemsAsync(IEnumerable App.HistoryWrapper.AddHistory(history); } - // Execute removal tasks concurrently in background - var sourcePaths = source.Select(x => x.Path); - _ = Task.WhenAll(sourcePaths.Select(jumpListService.RemoveFolderAsync)); + // A single Jump List update avoids a burst of concurrent COM calls after bulk moves. + var movedFolderPaths = source + .Where(item => item.ItemType == FilesystemItemType.Directory) + .Select(item => item.Path); + await jumpListService.RemoveFoldersAsync(movedFolderPaths); var itemsCount = banner.TotalItemsCount; diff --git a/src/Files.App/Utils/Storage/Operations/ShellFilesystemOperations.cs b/src/Files.App/Utils/Storage/Operations/ShellFilesystemOperations.cs index 8c9e60ff49d4..0c984da12fc6 100644 --- a/src/Files.App/Utils/Storage/Operations/ShellFilesystemOperations.cs +++ b/src/Files.App/Utils/Storage/Operations/ShellFilesystemOperations.cs @@ -11,7 +11,13 @@ namespace Files.App.Utils.Storage /// public sealed partial class ShellFilesystemOperations : IFilesystemOperations { + /// + /// Operations at or below this item count stay on the shell path to avoid Robocopy process overhead. + /// + private const int ROBOCOPY_MIN_ITEM_COUNT = 21; + private readonly IStorageTrashBinService StorageTrashBinService = Ioc.Default.GetRequiredService(); + private readonly IDevToolsSettingsService DevToolsSettingsService = Ioc.Default.GetRequiredService(); private IShellPage _associatedInstance; @@ -71,8 +77,13 @@ public async Task CopyItemsAsync(IList so var result = (FilesystemResult)true; var copyResult = new ShellOperationResult(); + // Small operations stay on the shell path to avoid Robocopy process overhead. + var canUseRobocopy = source.Count >= ROBOCOPY_MIN_ITEM_COUNT && + DevToolsSettingsService.UseRobocopyForFileOperations; + if (sourceRename.Any()) { + // Rename operations always use shell operations for proper incremental naming var resultItem = await FileOperationsHelpers.CopyItemAsync(sourceRename.Select(s => s.Path).ToArray(), destinationRename.ToArray(), false, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); result &= (FilesystemResult)resultItem.Item1; @@ -82,7 +93,9 @@ public async Task CopyItemsAsync(IList so if (sourceReplace.Any()) { - var resultItem = await FileOperationsHelpers.CopyItemAsync(sourceReplace.Select(s => s.Path).ToArray(), destinationReplace.ToArray(), true, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); + var resultItem = canUseRobocopy + ? await FileOperationsHelpers.CopyItemWithRobocopyAsync(sourceReplace.Select(s => s.Path).ToArray(), destinationReplace.ToArray(), true, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID, _associatedInstance) + : await FileOperationsHelpers.CopyItemAsync(sourceReplace.Select(s => s.Path).ToArray(), destinationReplace.ToArray(), true, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); result &= (FilesystemResult)resultItem.Item1; @@ -373,7 +386,9 @@ public async Task DeleteItemsAsync(IList var operationID = Guid.NewGuid().ToString(); await using var r = cancellationToken.Register(CancelOperation, operationID, false); - var (success, response) = await FileOperationsHelpers.DeleteItemAsync(deleteFilePaths.ToArray(), permanently, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); + var (success, response) = permanently && DevToolsSettingsService.UseRobocopyForFileOperations + ? await FileOperationsHelpers.DeleteItemWithRobocopyAsync(deleteFilePaths.ToArray(), MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID, _associatedInstance) + : await FileOperationsHelpers.DeleteItemAsync(deleteFilePaths.ToArray(), permanently, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); var result = (FilesystemResult)success; var deleteResult = new ShellOperationResult(); @@ -511,9 +526,21 @@ public async Task MoveItemsAsync(IList so var result = (FilesystemResult)true; var moveResult = new ShellOperationResult(); + // Small operations stay on the shell path to avoid Robocopy process overhead. + var preferRobocopy = source.Count >= ROBOCOPY_MIN_ITEM_COUNT && + DevToolsSettingsService.UseRobocopyForFileOperations; + if (sourceRename.Any()) { - var (status, response) = await FileOperationsHelpers.MoveItemAsync(sourceRename.Select(s => s.Path).ToArray(), destinationRename.ToArray(), false, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); + var renameItems = sourceRename.Zip(destinationRename, (src, dest) => (src, dest)).ToArray(); + var canUseRobocopy = preferRobocopy && + renameItems.All(item => + Path.GetFileName(item.src.Path).Equals(Path.GetFileName(item.dest), StringComparison.OrdinalIgnoreCase) && + !StorageHelpers.Exists(item.dest)) && + renameItems.Select(item => item.dest).Distinct(StringComparer.OrdinalIgnoreCase).Count() == renameItems.Length; + var (status, response) = canUseRobocopy + ? await FileOperationsHelpers.MoveItemWithRobocopyAsync(renameItems.Select(item => item.src.Path).ToArray(), renameItems.Select(item => item.dest).ToArray(), false, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID, _associatedInstance) + : await FileOperationsHelpers.MoveItemAsync(renameItems.Select(item => item.src.Path).ToArray(), renameItems.Select(item => item.dest).ToArray(), false, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); result &= (FilesystemResult)status; moveResult.Items.AddRange(response?.Final ?? Enumerable.Empty()); @@ -521,7 +548,13 @@ public async Task MoveItemsAsync(IList so if (sourceReplace.Any()) { - var (status, response) = await FileOperationsHelpers.MoveItemAsync(sourceReplace.Select(s => s.Path).ToArray(), destinationReplace.ToArray(), true, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); + var replaceItems = sourceReplace.Zip(destinationReplace, (src, dest) => (src, dest)).ToArray(); + var canUseRobocopy = preferRobocopy && + replaceItems.All(item => Path.GetFileName(item.src.Path).Equals(Path.GetFileName(item.dest), StringComparison.OrdinalIgnoreCase)) && + replaceItems.Select(item => item.dest).Distinct(StringComparer.OrdinalIgnoreCase).Count() == replaceItems.Length; + var (status, response) = canUseRobocopy + ? await FileOperationsHelpers.MoveItemWithRobocopyAsync(replaceItems.Select(item => item.src.Path).ToArray(), replaceItems.Select(item => item.dest).ToArray(), true, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID, _associatedInstance) + : await FileOperationsHelpers.MoveItemAsync(replaceItems.Select(item => item.src.Path).ToArray(), replaceItems.Select(item => item.dest).ToArray(), true, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID); result &= (FilesystemResult)status; moveResult.Items.AddRange(response?.Final ?? Enumerable.Empty()); diff --git a/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs b/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs index f4af5fa50be1..8fc1a7939286 100644 --- a/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs +++ b/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs @@ -21,6 +21,33 @@ public sealed partial class AdvancedViewModel : ObservableObject private ICommonDialogService CommonDialogService { get; } = Ioc.Default.GetRequiredService(); public ICommandManager Commands { get; } = Ioc.Default.GetRequiredService(); + public bool UseRobocopyForFileOperations + { + get => UserSettingsService.DevToolsSettingsService.UseRobocopyForFileOperations; + set + { + if (value != UserSettingsService.DevToolsSettingsService.UseRobocopyForFileOperations) + { + UserSettingsService.DevToolsSettingsService.UseRobocopyForFileOperations = value; + OnPropertyChanged(); + } + } + } + + public int RobocopyThreads + { + get => UserSettingsService.DevToolsSettingsService.RobocopyThreads; + set + { + var clamped = Math.Clamp(value, 1, 128); + if (clamped != UserSettingsService.DevToolsSettingsService.RobocopyThreads) + { + UserSettingsService.DevToolsSettingsService.RobocopyThreads = clamped; + OnPropertyChanged(); + } + } + } + private readonly IFileTagsSettingsService fileTagsSettingsService = Ioc.Default.GetRequiredService(); public ICommand SetAsDefaultExplorerCommand { get; } diff --git a/src/Files.App/Views/Settings/AdvancedPage.xaml b/src/Files.App/Views/Settings/AdvancedPage.xaml index 164dda1fd020..7c41a6e28814 100644 --- a/src/Files.App/Views/Settings/AdvancedPage.xaml +++ b/src/Files.App/Views/Settings/AdvancedPage.xaml @@ -206,6 +206,30 @@ + + + + + + + + + + + + +