Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
using System.Buffers;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>单个条目(文件/目录)处理后的结果。</summary>
|
||||
internal enum EntryOutcome
|
||||
{
|
||||
Success,
|
||||
Skipped,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拷贝引擎与作业运行器之间的回调边界。
|
||||
/// 引擎本身不认识 FileOperationJob / UI,只通过这个接口上报进度、询问冲突、请求取消,
|
||||
/// 因此可以脱离队列单独测试与复用。
|
||||
/// </summary>
|
||||
internal interface IJobSink
|
||||
{
|
||||
CancellationToken Token { get; }
|
||||
|
||||
bool IsCancellationRequested { get; }
|
||||
|
||||
/// <summary>若作业处于暂停态则挂起,直到继续或取消。每个拷贝块之间调用。</summary>
|
||||
Task WaitIfPausedAsync();
|
||||
|
||||
void AddBytes(long delta);
|
||||
|
||||
void AddCompletedItems(int delta);
|
||||
|
||||
void SetCurrentItem(string path);
|
||||
|
||||
/// <summary>记录一条非致命警告(重解析点跳过、时间戳设置失败等),进 job.Error。</summary>
|
||||
void Warn(string message);
|
||||
|
||||
/// <summary>记录一个失败条目;引擎会继续处理其余文件,绝不中止整批。</summary>
|
||||
void AddFailed(string path, string reason);
|
||||
|
||||
void AddSkipped(int delta = 1);
|
||||
|
||||
/// <summary>
|
||||
/// 记录一次真实的"搬运"以便撤销。
|
||||
/// 语义:<paramref name="newPath"/> 是搬运后的当前位置,<paramref name="originalPath"/> 是原位置;
|
||||
/// 撤销时把 newPath 搬回 originalPath。(同卷移动是 1 条;目录合并移动会产生多条。)
|
||||
/// </summary>
|
||||
void RecordMoveForUndo(string newPath, string originalPath);
|
||||
|
||||
/// <summary>向 UI 询问冲突处理方式。未设置回调 / 非 Ask 策略时由运行器按 Policy 直接决定,不阻塞。</summary>
|
||||
Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info);
|
||||
|
||||
/// <summary>用户选择了"取消",终止整个作业(已完成的部分保留,不回滚)。</summary>
|
||||
void RequestCancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文件复制 / 移动 / 删除的核心实现。
|
||||
///
|
||||
/// 关键设计:
|
||||
/// - 所有 Win32 文件 IO 走 \\?\ 长路径前缀(见 <see cref="PathHelper"/>);
|
||||
/// - 1MB 缓冲 + SequentialScan + 异步 IO,块与块之间检查暂停/取消;
|
||||
/// - 单文件失败只重试 3 次(100/300/900ms)后计入失败列表并继续,绝不因单个文件中断整批;
|
||||
/// - 同卷 Move 走 File.Move/Directory.Move(瞬时、不搬字节),跨卷才 Copy+Delete;
|
||||
/// 之所以不用 MoveFileEx/直接调 API:那样虽然也能跨卷搬,但拿不到字节级进度,
|
||||
/// 而"精确进度 + 可暂停/取消"是本引擎的核心诉求。
|
||||
/// </summary>
|
||||
internal static class CopyEngine
|
||||
{
|
||||
/// <summary>拷贝块大小:1MB。</summary>
|
||||
internal const int BufferSize = 1024 * 1024;
|
||||
|
||||
/// <summary>重试间隔(毫秒):共重试 3 次。</summary>
|
||||
private static readonly int[] RetryDelaysMs = [100, 300, 900];
|
||||
|
||||
// ---------------------------------------------------------------- 测量
|
||||
|
||||
/// <summary>
|
||||
/// 递归统计总字节数与总条目数(目录本身也算 1 个条目,和执行阶段的计数口径一致)。
|
||||
/// 不跟随重解析点;无法访问的项跳过。可取消。
|
||||
/// </summary>
|
||||
internal static (long Bytes, int Items) Measure(IReadOnlyList<string> paths, CancellationToken cancellationToken, Action<string>? onWarning = null)
|
||||
{
|
||||
long bytes = 0;
|
||||
var items = 0;
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return (bytes, items);
|
||||
|
||||
var attrs = PathHelper.TryGetAttributes(path);
|
||||
if (attrs is null)
|
||||
{
|
||||
onWarning?.Invoke($"测量时跳过无法访问的项:{path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
onWarning?.Invoke($"测量时跳过重解析点:{path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.Directory) == 0)
|
||||
{
|
||||
bytes += PathHelper.TryGetLength(path);
|
||||
items++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(path);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return (bytes, items);
|
||||
|
||||
var dir = stack.Pop();
|
||||
items++;
|
||||
|
||||
foreach (var child in PathHelper.EnumerateChildrenSafe(dir, onWarning))
|
||||
{
|
||||
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||
if (childAttrs is null) continue;
|
||||
if ((childAttrs & FileAttributes.ReparsePoint) != 0) continue; // 不跟随,避免无限递归
|
||||
|
||||
if ((childAttrs & FileAttributes.Directory) != 0) stack.Push(child);
|
||||
else
|
||||
{
|
||||
bytes += PathHelper.TryGetLength(child);
|
||||
items++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (bytes, items);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 复制
|
||||
|
||||
/// <summary>复制一个条目(文件或目录),内部处理冲突策略。</summary>
|
||||
internal static async Task<EntryOutcome> CopyEntryAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var attrs = PathHelper.TryGetAttributes(source);
|
||||
if (attrs is null)
|
||||
{
|
||||
sink.AddFailed(source, "源不存在或无法访问。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
}
|
||||
|
||||
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||
|
||||
if (PathHelper.Exists(destination))
|
||||
{
|
||||
var resolution = await sink.ResolveConflictAsync(BuildConflictInfo(source, destination)).ConfigureAwait(false);
|
||||
switch (resolution)
|
||||
{
|
||||
case ConflictResolution.Cancel:
|
||||
sink.RequestCancel();
|
||||
return EntryOutcome.Cancelled;
|
||||
|
||||
case ConflictResolution.Skip:
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
|
||||
case ConflictResolution.KeepBoth:
|
||||
destination = PathHelper.MakeUniquePath(destination);
|
||||
break;
|
||||
|
||||
default: // Replace:目录对目录 = 合并;文件对文件 = 先删目标再复制
|
||||
var destinationIsDir = PathHelper.DirectoryExists(destination);
|
||||
if (sourceIsDir && destinationIsDir) break; // 合并,保留目标目录
|
||||
if (sourceIsDir != destinationIsDir)
|
||||
{
|
||||
sink.AddFailed(source, sourceIsDir
|
||||
? "目标位置存在同名文件,无法用文件夹替换文件。"
|
||||
: "目标位置存在同名文件夹,无法用文件替换文件夹。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
var removed = await DeletePermanentAsync(destination, sink, countAsItem: false).ConfigureAwait(false);
|
||||
if (removed != EntryOutcome.Success) return removed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sourceIsDir
|
||||
? await CopyDirectoryAsync(source, destination, sink).ConfigureAwait(false)
|
||||
: await CopyFileAsync(source, destination, sink).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<EntryOutcome> CopyFileAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
long attemptBytes = 0;
|
||||
var ok = await RetryAsync(
|
||||
async () =>
|
||||
{
|
||||
attemptBytes = 0;
|
||||
sink.SetCurrentItem(source);
|
||||
PathHelper.EnsureParentDirectory(destination);
|
||||
await CopyFileCoreAsync(source, destination, sink,
|
||||
n =>
|
||||
{
|
||||
attemptBytes += n; // 重试时用于回退已上报的字节数
|
||||
sink.AddBytes(n); // 真正的进度上报(限频由 sink 负责)
|
||||
}).ConfigureAwait(false);
|
||||
},
|
||||
source,
|
||||
sink,
|
||||
onRetry: () => { if (attemptBytes > 0) sink.AddBytes(-attemptBytes); }).ConfigureAwait(false);
|
||||
|
||||
if (ok)
|
||||
{
|
||||
sink.AddCompletedItems(1);
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
return sink.IsCancellationRequested ? EntryOutcome.Cancelled : EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
private static async Task CopyFileCoreAsync(string source, string destination, IJobSink sink, Action<long> reportBytes)
|
||||
{
|
||||
var sourceExtended = PathHelper.ToExtended(source);
|
||||
var destinationExtended = PathHelper.ToExtended(destination);
|
||||
|
||||
// 目标已存在且只读:必须先去只读,否则 Create 会抛 UnauthorizedAccessException。
|
||||
PathHelper.ClearReadOnly(destination);
|
||||
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
try
|
||||
{
|
||||
await using (var input = new FileStream(sourceExtended, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
await using (var output = new FileStream(destinationExtended, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) throw new OperationCanceledException(sink.Token);
|
||||
|
||||
var read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), sink.Token).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), sink.Token).ConfigureAwait(false);
|
||||
reportBytes(read);
|
||||
}
|
||||
|
||||
await output.FlushAsync(sink.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
// 保留时间戳与属性(只读属性最后设置)。
|
||||
try
|
||||
{
|
||||
var sourceAttrs = PathHelper.TryGetAttributes(source) ?? FileAttributes.Normal;
|
||||
File.SetLastWriteTimeUtc(destinationExtended, File.GetLastWriteTimeUtc(sourceExtended));
|
||||
File.SetCreationTimeUtc(destinationExtended, File.GetCreationTimeUtc(sourceExtended));
|
||||
File.SetAttributes(destinationExtended,
|
||||
sourceAttrs & ~(FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
sink.Warn($"已复制但无法保留时间戳/属性:{destination}({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<EntryOutcome> CopyDirectoryAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
if (IsSameOrSubPathOf(destination, source))
|
||||
{
|
||||
sink.AddFailed(source, "目标路径位于源目录内部,已拒绝执行(会造成无限递归)。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
// 用显式栈做迭代式递归,避免极深目录树耗尽调用栈。
|
||||
var stack = new Stack<(string Source, string Destination)>();
|
||||
stack.Push((source, destination));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var (currentSource, currentDestination) = stack.Pop();
|
||||
|
||||
var created = await RetryAsync(
|
||||
() =>
|
||||
{
|
||||
PathHelper.EnsureParentDirectory(currentDestination);
|
||||
Directory.CreateDirectory(PathHelper.ToExtended(currentDestination));
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
currentDestination,
|
||||
sink).ConfigureAwait(false);
|
||||
|
||||
if (!created) return EntryOutcome.Failed;
|
||||
|
||||
sink.SetCurrentItem(currentDestination);
|
||||
sink.AddCompletedItems(1);
|
||||
|
||||
foreach (var child in PathHelper.EnumerateChildrenSafe(currentSource, sink.Warn))
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||
if (childAttrs is null)
|
||||
{
|
||||
sink.AddFailed(child, "无法读取属性(可能已被删除或拒绝访问)。");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((childAttrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{child}");
|
||||
sink.AddSkipped();
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = PathHelper.Combine(currentDestination, PathHelper.GetFileName(child));
|
||||
if ((childAttrs & FileAttributes.Directory) != 0)
|
||||
{
|
||||
stack.Push((child, target));
|
||||
}
|
||||
else
|
||||
{
|
||||
var outcome = await CopyEntryAsync(child, target, sink).ConfigureAwait(false);
|
||||
if (outcome == EntryOutcome.Cancelled) return EntryOutcome.Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 移动
|
||||
|
||||
/// <summary>移动一个条目,内部处理冲突策略。</summary>
|
||||
internal static async Task<EntryOutcome> MoveEntryAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var attrs = PathHelper.TryGetAttributes(source);
|
||||
if (attrs is null)
|
||||
{
|
||||
sink.AddFailed(source, "源不存在或无法访问。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
}
|
||||
|
||||
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||
|
||||
if (PathHelper.Exists(destination))
|
||||
{
|
||||
var resolution = await sink.ResolveConflictAsync(BuildConflictInfo(source, destination)).ConfigureAwait(false);
|
||||
switch (resolution)
|
||||
{
|
||||
case ConflictResolution.Cancel:
|
||||
sink.RequestCancel();
|
||||
return EntryOutcome.Cancelled;
|
||||
|
||||
case ConflictResolution.Skip:
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
|
||||
case ConflictResolution.KeepBoth:
|
||||
destination = PathHelper.MakeUniquePath(destination);
|
||||
break;
|
||||
|
||||
default:
|
||||
var destinationIsDir = PathHelper.DirectoryExists(destination);
|
||||
if (sourceIsDir && destinationIsDir) break; // 目录对目录:合并(递归搬运子项)
|
||||
if (sourceIsDir != destinationIsDir)
|
||||
{
|
||||
sink.AddFailed(source, sourceIsDir
|
||||
? "目标位置存在同名文件,无法用文件夹替换文件。"
|
||||
: "目标位置存在同名文件夹,无法用文件替换文件夹。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
var removed = await DeletePermanentAsync(destination, sink, countAsItem: false).ConfigureAwait(false);
|
||||
if (removed != EntryOutcome.Success) return removed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceIsDir && PathHelper.DirectoryExists(destination))
|
||||
return await MoveDirectoryMergedAsync(source, destination, sink).ConfigureAwait(false);
|
||||
|
||||
return await MoveSingleAsync(source, destination, sourceIsDir, sink).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 不做冲突询问的移动(重命名、撤销还原用):
|
||||
/// 调用方必须已经保证目标路径不冲突,或已经自行决定好冲突处理方式。
|
||||
/// </summary>
|
||||
internal static async Task<EntryOutcome> MoveDirectAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var attrs = PathHelper.TryGetAttributes(source);
|
||||
if (attrs is null)
|
||||
{
|
||||
sink.AddFailed(source, "源不存在或无法访问。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
}
|
||||
|
||||
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||
|
||||
if (sourceIsDir && PathHelper.DirectoryExists(destination))
|
||||
return await MoveDirectoryMergedAsync(source, destination, sink).ConfigureAwait(false);
|
||||
|
||||
return await MoveSingleAsync(source, destination, sourceIsDir, sink).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<EntryOutcome> MoveSingleAsync(string source, string destination, bool sourceIsDir, IJobSink sink)
|
||||
{
|
||||
// 同卷:File.Move / Directory.Move 是纯元数据操作,瞬时完成,不产生任何字节流量。
|
||||
if (PathHelper.SameVolume(source, destination))
|
||||
{
|
||||
var moved = await RetryAsync(
|
||||
() =>
|
||||
{
|
||||
sink.SetCurrentItem(source);
|
||||
PathHelper.EnsureParentDirectory(destination);
|
||||
var s = PathHelper.ToExtended(source);
|
||||
var d = PathHelper.ToExtended(destination);
|
||||
if (sourceIsDir) Directory.Move(s, d);
|
||||
else File.Move(s, d);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
source,
|
||||
sink).ConfigureAwait(false);
|
||||
|
||||
if (moved)
|
||||
{
|
||||
sink.AddCompletedItems(1);
|
||||
sink.RecordMoveForUndo(destination, source);
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
// 同卷判定失败(例如跨卷挂载点/Junction)时退化:复制成功后删源,仍有字节级进度。
|
||||
sink.Warn($"同卷移动失败,自动改用“复制后删除”:{source}");
|
||||
}
|
||||
|
||||
var copied = sourceIsDir
|
||||
? await CopyDirectoryAsync(source, destination, sink).ConfigureAwait(false)
|
||||
: await CopyEntryAsync(source, destination, sink).ConfigureAwait(false);
|
||||
|
||||
if (copied != EntryOutcome.Success) return copied;
|
||||
|
||||
sink.RecordMoveForUndo(destination, source);
|
||||
|
||||
var deleted = await DeletePermanentAsync(source, sink, countAsItem: false).ConfigureAwait(false);
|
||||
return deleted == EntryOutcome.Success ? EntryOutcome.Success : deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目录合并移动:目标目录已存在时,逐个搬子项。
|
||||
/// 同卷时每个子项都是瞬时的 File.Move;同名子项按冲突策略处理。
|
||||
/// </summary>
|
||||
private static async Task<EntryOutcome> MoveDirectoryMergedAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
Directory.CreateDirectory(PathHelper.ToExtended(destination));
|
||||
|
||||
foreach (var child in PathHelper.EnumerateChildrenSafe(source, sink.Warn))
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var target = PathHelper.Combine(destination, PathHelper.GetFileName(child));
|
||||
var outcome = await MoveEntryAsync(child, target, sink).ConfigureAwait(false);
|
||||
if (outcome == EntryOutcome.Cancelled) return EntryOutcome.Cancelled;
|
||||
}
|
||||
|
||||
TryDeleteEmptyDirectory(source);
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 删除
|
||||
|
||||
/// <summary>永久删除(不进回收站)。目录采用"后序迭代删除",逐个条目上报进度。</summary>
|
||||
internal static async Task<EntryOutcome> DeletePermanentAsync(string path, IJobSink sink, bool countAsItem)
|
||||
{
|
||||
var attrs = PathHelper.TryGetAttributes(path);
|
||||
if (attrs is null)
|
||||
{
|
||||
if (countAsItem) sink.AddFailed(path, "路径不存在或无法访问。");
|
||||
return countAsItem ? EntryOutcome.Failed : EntryOutcome.Success;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.Directory) == 0)
|
||||
{
|
||||
var ok = await RetryAsync(
|
||||
() =>
|
||||
{
|
||||
sink.SetCurrentItem(path);
|
||||
PathHelper.ClearReadOnly(path);
|
||||
File.Delete(PathHelper.ToExtended(path));
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
path,
|
||||
sink).ConfigureAwait(false);
|
||||
|
||||
if (!ok) return sink.IsCancellationRequested ? EntryOutcome.Cancelled : EntryOutcome.Failed;
|
||||
if (countAsItem) sink.AddCompletedItems(1);
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
var stack = new Stack<(string Path, bool Expanded)>();
|
||||
stack.Push((path, false));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var (current, expanded) = stack.Pop();
|
||||
|
||||
if (!expanded)
|
||||
{
|
||||
stack.Push((current, true));
|
||||
foreach (var child in PathHelper.EnumerateChildrenSafe(current, sink.Warn))
|
||||
{
|
||||
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||
if (childAttrs is null) continue;
|
||||
|
||||
// 重解析点:只删链接本身,绝不递归进去(否则会删掉链接目标的内容)。
|
||||
if ((childAttrs & FileAttributes.ReparsePoint) != 0 || (childAttrs & FileAttributes.Directory) == 0)
|
||||
stack.Push((child, true));
|
||||
else
|
||||
stack.Push((child, false));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDirectory = PathHelper.DirectoryExists(current);
|
||||
var deleted = await RetryAsync(
|
||||
() =>
|
||||
{
|
||||
sink.SetCurrentItem(current);
|
||||
PathHelper.ClearReadOnly(current);
|
||||
var extended = PathHelper.ToExtended(current);
|
||||
if (isDirectory) Directory.Delete(extended, recursive: false);
|
||||
else File.Delete(extended);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
current,
|
||||
sink).ConfigureAwait(false);
|
||||
|
||||
if (deleted && countAsItem) sink.AddCompletedItems(1);
|
||||
}
|
||||
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 通用
|
||||
|
||||
/// <summary>重试包装:IOException / UnauthorizedAccessException 重试 3 次(100/300/900ms),
|
||||
/// 仍失败则计入失败列表并返回 false,由调用方继续处理其余文件。</summary>
|
||||
private static async Task<bool> RetryAsync(Func<Task> action, string path, IJobSink sink, Action? onRetry = null)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await action().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (attempt < RetryDelaysMs.Length && IsTransient(ex))
|
||||
{
|
||||
onRetry?.Invoke();
|
||||
try
|
||||
{
|
||||
await Task.Delay(RetryDelaysMs[attempt], sink.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onRetry?.Invoke();
|
||||
sink.AddFailed(path, Describe(ex));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTransient(Exception ex) => ex is IOException or UnauthorizedAccessException;
|
||||
|
||||
private static string Describe(Exception ex) => ex switch
|
||||
{
|
||||
UnauthorizedAccessException => "拒绝访问(文件可能被占用或权限不足)。",
|
||||
DirectoryNotFoundException => "目录不存在(可能已被移动或删除)。",
|
||||
FileNotFoundException => "文件不存在(可能已被移动或删除)。",
|
||||
PathTooLongException => "路径过长。",
|
||||
_ => ex.Message
|
||||
};
|
||||
|
||||
internal static ConflictInfo BuildConflictInfo(string source, string destination)
|
||||
{
|
||||
var sourceAttrs = PathHelper.TryGetAttributes(source) ?? 0;
|
||||
var destinationAttrs = PathHelper.TryGetAttributes(destination) ?? 0;
|
||||
var sourceIsDir = (sourceAttrs & FileAttributes.Directory) != 0;
|
||||
var destinationIsDir = (destinationAttrs & FileAttributes.Directory) != 0;
|
||||
|
||||
return new ConflictInfo
|
||||
{
|
||||
SourcePath = source,
|
||||
DestinationPath = destination,
|
||||
SourceIsDirectory = sourceIsDir,
|
||||
SourceSize = sourceIsDir ? 0 : PathHelper.TryGetLength(source),
|
||||
DestinationSize = destinationIsDir ? 0 : PathHelper.TryGetLength(destination),
|
||||
SourceModifiedUtc = TryGetModifiedUtc(source),
|
||||
DestinationModifiedUtc = TryGetModifiedUtc(destination)
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime TryGetModifiedUtc(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.GetLastWriteTimeUtc(PathHelper.ToExtended(path));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>candidate 是否等于 root 或位于 root 之内(用于拒绝"复制到自身内部")。</summary>
|
||||
internal static bool IsSameOrSubPathOf(string candidate, string root)
|
||||
{
|
||||
var c = (PathHelper.TryGetFullPath(candidate) ?? candidate)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var r = (PathHelper.TryGetFullPath(root) ?? root)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
if (string.Equals(c, r, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return c.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void TryDeleteEmptyDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var extended = PathHelper.ToExtended(path);
|
||||
if (Directory.Exists(extended)) Directory.Delete(extended, recursive: false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 目录里还有没搬走的项(例如同名冲突被 Skip 了):保留,不算失败。
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user