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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+