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 1776dce2960d..e5efaeca8c98 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..80578bb7be43 100644
--- a/src/Files.App/Data/Enums/CopyEngineResult.cs
+++ b/src/Files.App/Data/Enums/CopyEngineResult.cs
@@ -14,6 +14,7 @@ public struct CopyEngineResult
public const int COPYENGINE_E_ACCESS_DENIED_SRC = -2144927711;
public const int COPYENGINE_E_ACCESS_DENIED_DEST = -2144927710;
public const int COPYENGINE_E_REQUIRES_ELEVATION = -2144927742;
+ public const int HRESULT_ERROR_ACCESS_DENIED = -2147024891;
// Path too long
public const int COPYENGINE_E_PATH_TOO_DEEP_SRC = -2144927715;
public const int COPYENGINE_E_PATH_TOO_DEEP_DEST = -2144927714;
@@ -63,8 +64,10 @@ 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.HRESULT_ERROR_ACCESS_DENIED => FileSystemStatusCode.Unauthorized,
CopyEngineResult.COPYENGINE_E_RECYCLE_PATH_TOO_LONG => FileSystemStatusCode.NameTooLong,
CopyEngineResult.COPYENGINE_E_NEWFILE_NAME_TOO_LONG => FileSystemStatusCode.NameTooLong,
CopyEngineResult.COPYENGINE_E_NEWFOLDER_NAME_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 a44bae8f7896..e8301db7f19a 100644
--- a/src/Files.App/Services/Settings/UserSettingsService.cs
+++ b/src/Files.App/Services/Settings/UserSettingsService.cs
@@ -88,6 +88,12 @@ public IAppSettingsService AppSettingsService
get => GetSettingsService(ref _AppSettingsService);
}
+ private IDevToolsSettingsService? _DevToolsSettingsService;
+ public IDevToolsSettingsService DevToolsSettingsService
+ {
+ get => GetSettingsService(ref _DevToolsSettingsService);
+ }
+
public UserSettingsService()
{
var settingsSerializer = new DefaultSettingsSerializer();
diff --git a/src/Files.App/Services/Windows/WindowsJumpListService.cs b/src/Files.App/Services/Windows/WindowsJumpListService.cs
index 91700ca6ba81..1e8f649a95eb 100644
--- a/src/Files.App/Services/Windows/WindowsJumpListService.cs
+++ b/src/Files.App/Services/Windows/WindowsJumpListService.cs
@@ -113,19 +113,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 c60e936b9ede..b5d3ddfba6e6 100644
--- a/src/Files.App/Strings/en-US/Resources.resw
+++ b/src/Files.App/Strings/en-US/Resources.resw
@@ -2186,6 +2186,15 @@
Show flatten options
+
+ Use Robocopy for file operations
+
+
+ Use multi-threaded Robocopy engine for large copy and move 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 c77e55de9b16..b246d81367ea 100644
--- a/src/Files.App/Utils/StatusCenter/StatusCenterItem.cs
+++ b/src/Files.App/Utils/StatusCenter/StatusCenterItem.cs
@@ -150,9 +150,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; }
@@ -204,8 +204,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();
if (Operation is FileOperationType.Git)
{
diff --git a/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs
index 7ca8658d6713..5ccf3a732049 100644
--- a/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs
+++ b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs
@@ -22,7 +22,10 @@ public sealed partial class FileOperationsHelpers
private static readonly PROPERTYKEY PKEY_FilePlaceholderStatus = new() { fmtid = new("B2F9B9D6-FEC4-4DD5-94D7-8957488C807B"), pid = 2 };
private const uint PS_CLOUDFILE_PLACEHOLDER = 8;
+ private static IDevToolsSettingsService DevToolsSettingsService => field ??= 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)
{
@@ -672,8 +675,640 @@ 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,
+ ownerHwnd,
+ asAdmin,
+ 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,
+ ownerHwnd,
+ asAdmin,
+ progress,
+ operationID,
+ shellPage,
+ isMoveOperation: true);
+ }
+
+ ///
+ /// Checks all source descendants for reparse points without following them.
+ ///
+ private static bool AreRobocopySourcesSafe(IEnumerable sourcePaths, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var pending = new Stack(sourcePaths.Select(path =>
+ Win32Helper.HasFileAttribute(path, FileAttributes.Directory)
+ ? new DirectoryInfo(path)
+ : new FileInfo(path)));
+ while (pending.TryPop(out var item))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (item.Attributes.HasFlag(FileAttributes.ReparsePoint))
+ return false;
+
+ if (item is DirectoryInfo directory)
+ {
+ foreach (var child in directory.EnumerateFileSystemInfos())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ pending.Push(child);
+ }
+ }
+ }
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ App.Logger?.LogWarning(ex, "Unable to safely enumerate Robocopy sources");
+ return false;
+ }
+ }
+
+ private static async Task<(bool success, int hResult)> RunRobocopyAsync(IReadOnlyList arguments, StatusCenterItemProgressModel? progressModel, IReadOnlyCollection? expectedItemNames, string operationID, CancellationToken cancellationToken)
+ {
+ try
+ {
+ App.Logger?.LogInformation($"Robocopy operation {operationID}: Starting with arguments: {string.Join(" ", 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",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ StandardOutputEncoding = oemEncoding,
+ StandardErrorEncoding = oemEncoding
+ };
+ foreach (var argument in arguments)
+ psi.ArgumentList.Add(argument);
+
+ 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;
+ else if (e.Data.Contains("(0x00000005)", StringComparison.OrdinalIgnoreCase))
+ hResult = CopyEngineResult.HRESULT_ERROR_ACCESS_DENIED;
+
+ 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 registration = cancellationToken.Register(() =>
+ {
+ try
+ {
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+ catch (Exception ex)
+ {
+ App.Logger?.LogWarning(ex, "Robocopy operation {OperationId}: Failed to terminate process", operationID);
+ }
+ });
+
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ try
+ {
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+ catch (Exception ex)
+ {
+ App.Logger?.LogWarning(ex, "Robocopy operation {OperationId}: Failed to terminate cancelled process", operationID);
+ }
+
+ try
+ {
+ await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10));
+ await Task.WhenAll(outputCompleted.Task, errorTask).WaitAsync(TimeSpan.FromSeconds(10));
+ }
+ catch (Exception ex)
+ {
+ App.Logger?.LogWarning(ex, "Robocopy operation {OperationId}: Process did not finish cleanly after cancellation", operationID);
+ }
+
+ App.Logger?.LogWarning($"Robocopy operation {operationID}: Cancelled");
+ return (false, -3);
+ }
+
+ await Task.WhenAll(outputCompleted.Task, errorTask);
+
+ var standardError = await errorTask;
+ if (standardError.Contains("(0x00000020)", StringComparison.OrdinalIgnoreCase))
+ hResult = CopyEngineResult.HRESULT_ERROR_SHARING_VIOLATION;
+ else if (standardError.Contains("(0x00000005)", StringComparison.OrdinalIgnoreCase))
+ hResult = CopyEngineResult.HRESULT_ERROR_ACCESS_DENIED;
+
+ var exitCode = process.ExitCode;
+ // Bit 4 means mismatched files; treating it as success can hide a partial move.
+ // An inaccessible directory can exhaust retries without setting an exit-code error bit.
+ var success = exitCode is >= 0 and <= 3 && (exitCode != 0 || hResult == -1);
+ if (!success)
+ {
+ App.Logger?.LogWarning($"Robocopy operation {operationID}: Exit code {exitCode}. {standardError}");
+ }
+ 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);
+ }
+ }
+
+ ///
+ /// Enumerates item names for batch result verification.
+ ///
+ private static HashSet? EnumerateItemNames(string directoryPath)
+ {
+ try
+ {
+ return Directory.EnumerateFileSystemEntries(directoryPath)
+ .Select(path => Path.GetFileName(path))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ App.Logger?.LogWarning(ex, "Unable to verify Robocopy results in {DirectoryPath}", directoryPath);
+ return null;
+ }
+ }
+
+ 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,
+ long ownerHwnd,
+ bool asAdmin,
+ 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;
+
+ fsProgress.ItemsCount = filePaths.Length;
+ fsProgress.Report();
+ progressHandler ??= new();
+
+ return Task.Run(async () =>
+ {
+ var shellOperationResult = new ShellOperationResult();
+ var success = true;
+
+ Task sizeTask = Task.CompletedTask;
+ App.Logger?.LogInformation($"Robocopy {(isMoveOperation ? "move" : "copy")} operation {operationID}: Processing {filePaths.Length} items");
+
+ // Initial progress update
+ fsProgress.Report(0);
+
+ try
+ {
+ if (asAdmin || !AreRobocopySourcesSafe(filePaths, cts.Token))
+ {
+ return isMoveOperation
+ ? await MoveItemAsync(filePaths, destinationPaths, overwriteOnOperation, ownerHwnd, asAdmin, progress!, operationID)
+ : await CopyItemAsync(filePaths, destinationPaths, overwriteOnOperation, ownerHwnd, asAdmin, progress!, operationID);
+ }
+
+ var sizeCalculator = new FileSizeCalculator(filePaths);
+ 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);
+
+ 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
+ };
+ argsList.AddRange(itemNames);
+ argsList.AddRange([
+ "/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 = argsList;
+
+ // check if the argsList is longer than 8000 characters
+ if (robocopyArgs.Sum(argument => argument.Length + 3) > 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.Sum(argument => argument.Length + 3)}");
+ (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;
+ var destinationNames = EnumerateItemNames(destDir);
+ var remainingSourceNames = isMoveOperation ? EnumerateItemNames(sourceDir) : null;
+ foreach (var itemName in itemNames)
+ {
+ var sourcePath = Path.Combine(sourceDir, itemName);
+ var destinationPath = Path.Combine(destDir, itemName);
+ var itemOk = batchOk && destinationNames is not null && destinationNames.Contains(itemName) &&
+ (!isMoveOperation || remainingSourceNames is not null && !remainingSourceNames.Contains(itemName));
+ 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",
+ "/XJ",
+ "/SL",
+ "/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 = 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 { } page)
+ {
+ await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() =>
+ page.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 (Exception ex)
+ {
+ App.Logger?.LogWarning(ex, "Robocopy size calculation did not finish cleanly");
+ }
+ cts.Dispose();
+ }
+
+ return (success, shellOperationResult);
+ });
+ }
+
public static void TryCancelOperation(string operationId)
- => progressHandler?.TryCancel(operationId);
+ {
+ progressHandler?.TryCancel(operationId);
+ if (robocopyOperationTokens.TryGetValue(operationId, out var cts))
+ {
+ try
+ {
+ cts.Cancel();
+ }
+ catch (Exception ex)
+ {
+ App.Logger?.LogWarning(ex, "Unable to cancel file operation {OperationId}", operationId);
+ }
+ }
+ }
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 818bdb3b1705..1193ec71e504 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 && history is not null)
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;
@@ -502,9 +503,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 fa783e2a66c1..d4b46309e5e7 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
{
+ ///
+ /// File-only selections at or below this count stay on the shell path to avoid Robocopy process overhead.
+ ///
+ private const int ROBOCOPY_ITEM_COUNT_THRESHOLD = 20;
+
private readonly IStorageTrashBinService StorageTrashBinService = Ioc.Default.GetRequiredService();
+ private readonly IDevToolsSettingsService DevToolsSettingsService = Ioc.Default.GetRequiredService();
private readonly IShellPage _associatedInstance;
@@ -76,9 +82,20 @@ public ShellFilesystemOperations(IShellPage associatedInstance)
var result = (FilesystemResult)true;
var copyResult = new ShellOperationResult();
+ var preferRobocopy = !asAdmin && DevToolsSettingsService.UseRobocopyForFileOperations &&
+ (sourceNoSkip.Count() > ROBOCOPY_ITEM_COUNT_THRESHOLD || sourceNoSkip.Any(item => item.ItemType == FilesystemItemType.Directory));
+
if (sourceRename.Any())
{
- var resultItem = await FileOperationsHelpers.CopyItemAsync(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 resultItem = canUseRobocopy
+ ? await FileOperationsHelpers.CopyItemWithRobocopyAsync(renameItems.Select(item => item.src.Path).ToArray(), renameItems.Select(item => item.dest).ToArray(), false, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID, _associatedInstance)
+ : await FileOperationsHelpers.CopyItemAsync(renameItems.Select(item => item.src.Path).ToArray(), renameItems.Select(item => item.dest).ToArray(), false, MainWindow.Instance.WindowHandle.ToInt64(), asAdmin, progress, operationID);
result &= (FilesystemResult)resultItem.Item1;
@@ -87,7 +104,13 @@ public ShellFilesystemOperations(IShellPage associatedInstance)
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 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 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;
@@ -454,7 +477,12 @@ public async Task CreateShortcutItemsAsync(IList source.DistinctBy(x => x.Path).SingleOrDefault(s => s.Path == x.Source)).WhereNotNull().ToListAsync(), progress, permanently, cancellationToken);
+ // Shell errors can refer to a child of a selected folder.
+ var sourcesToRetry = source.DistinctBy(item => item.Path).Where(item => failedSources.Any(failed =>
+ failed.Source is { } failedPath &&
+ (item.Path.Equals(failedPath, StringComparison.OrdinalIgnoreCase) ||
+ item.ItemType == FilesystemItemType.Directory && failedPath.IsSubPathOf(item.Path))));
+ return await DeleteItemsAsync(await sourcesToRetry.ToListAsync(), progress, permanently, cancellationToken);
}
}
else if (deleteResult.Items.Any(x => CopyEngineResult.Convert(x.HResult) == FileSystemStatusCode.NameTooLong))
@@ -527,9 +555,20 @@ public async Task CreateShortcutItemsAsync(IList ROBOCOPY_ITEM_COUNT_THRESHOLD || sourceNoSkip.Any(item => item.ItemType == FilesystemItemType.Directory));
+
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());
@@ -537,7 +576,13 @@ public async Task CreateShortcutItemsAsync(IList 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 16b7213dea00..ccb823d5221c 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 0a419f3bb011..470f94c4b001 100644
--- a/src/Files.App/Views/Settings/AdvancedPage.xaml
+++ b/src/Files.App/Views/Settings/AdvancedPage.xaml
@@ -194,6 +194,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+