using System.Buffers; namespace FluidExplorer.Services.Operations; /// 单个条目(文件/目录)处理后的结果。 internal enum EntryOutcome { Success, Skipped, Failed, Cancelled } /// /// 拷贝引擎与作业运行器之间的回调边界。 /// 引擎本身不认识 FileOperationJob / UI,只通过这个接口上报进度、询问冲突、请求取消, /// 因此可以脱离队列单独测试与复用。 /// internal interface IJobSink { CancellationToken Token { get; } bool IsCancellationRequested { get; } /// 若作业处于暂停态则挂起,直到继续或取消。每个拷贝块之间调用。 Task WaitIfPausedAsync(); void AddBytes(long delta); void AddCompletedItems(int delta); void SetCurrentItem(string path); /// 记录一条非致命警告(重解析点跳过、时间戳设置失败等),进 job.Error。 void Warn(string message); /// 记录一个失败条目;引擎会继续处理其余文件,绝不中止整批。 void AddFailed(string path, string reason); void AddSkipped(int delta = 1); /// /// 记录一次真实的"搬运"以便撤销。 /// 语义: 是搬运后的当前位置, 是原位置; /// 撤销时把 newPath 搬回 originalPath。(同卷移动是 1 条;目录合并移动会产生多条。) /// void RecordMoveForUndo(string newPath, string originalPath); /// 向 UI 询问冲突处理方式。未设置回调 / 非 Ask 策略时由运行器按 Policy 直接决定,不阻塞。 Task ResolveConflictAsync(ConflictInfo info); /// 用户选择了"取消",终止整个作业(已完成的部分保留,不回滚)。 void RequestCancel(); } /// /// 文件复制 / 移动 / 删除的核心实现。 /// /// 关键设计: /// - 所有 Win32 文件 IO 走 \\?\ 长路径前缀(见 ); /// - 1MB 缓冲 + SequentialScan + 异步 IO,块与块之间检查暂停/取消; /// - 单文件失败只重试 3 次(100/300/900ms)后计入失败列表并继续,绝不因单个文件中断整批; /// - 同卷 Move 走 File.Move/Directory.Move(瞬时、不搬字节),跨卷才 Copy+Delete; /// 之所以不用 MoveFileEx/直接调 API:那样虽然也能跨卷搬,但拿不到字节级进度, /// 而"精确进度 + 可暂停/取消"是本引擎的核心诉求。 /// internal static class CopyEngine { /// 拷贝块大小:1MB。 internal const int BufferSize = 1024 * 1024; /// 重试间隔(毫秒):共重试 3 次。 private static readonly int[] RetryDelaysMs = [100, 300, 900]; // ---------------------------------------------------------------- 测量 /// /// 递归统计总字节数与总条目数(目录本身也算 1 个条目,和执行阶段的计数口径一致)。 /// 不跟随重解析点;无法访问的项跳过。可取消。 /// internal static (long Bytes, int Items) Measure(IReadOnlyList paths, CancellationToken cancellationToken, Action? 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(); 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); } // ---------------------------------------------------------------- 复制 /// 复制一个条目(文件或目录),内部处理冲突策略。 internal static async Task 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 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 reportBytes) { var sourceExtended = PathHelper.ToExtended(source); var destinationExtended = PathHelper.ToExtended(destination); // 目标已存在且只读:必须先去只读,否则 Create 会抛 UnauthorizedAccessException。 PathHelper.ClearReadOnly(destination); var buffer = ArrayPool.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.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 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; } // ---------------------------------------------------------------- 移动 /// 移动一个条目,内部处理冲突策略。 internal static async Task 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); } /// /// 不做冲突询问的移动(重命名、撤销还原用): /// 调用方必须已经保证目标路径不冲突,或已经自行决定好冲突处理方式。 /// internal static async Task 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 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; } /// /// 目录合并移动:目标目录已存在时,逐个搬子项。 /// 同卷时每个子项都是瞬时的 File.Move;同名子项按冲突策略处理。 /// private static async Task 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; } // ---------------------------------------------------------------- 删除 /// 永久删除(不进回收站)。目录采用"后序迭代删除",逐个条目上报进度。 internal static async Task 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; } // ---------------------------------------------------------------- 通用 /// 重试包装:IOException / UnauthorizedAccessException 重试 3 次(100/300/900ms), /// 仍失败则计入失败列表并返回 false,由调用方继续处理其余文件。 private static async Task RetryAsync(Func 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; } } /// candidate 是否等于 root 或位于 root 之内(用于拒绝"复制到自身内部")。 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 了):保留,不算失败。 } } }