Files
fluid-explorer/Services/Operations/FileOperationService.cs
T

923 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Text;
namespace FluidExplorer.Services.Operations;
/// <summary>
/// 文件操作引擎(纯 .NET 实现,不引用任何 WinUI 类型,可独立测试与复用)。
///
/// 线程模型:
/// - 所有作业都在后台 worker 上执行,UI 线程只负责入队/暂停/继续/取消,永不阻塞;
/// - 默认串行执行(避免多作业同时读写同一块磁盘造成抖动);
/// 同卷 Move / 重命名 / 新建文件夹这类"瞬时元数据操作"走并行车道,不占用串行队首;
/// - 进度回调按 50ms 限频,避免 UI 事件风暴。
/// </summary>
public sealed class FileOperationService : IFileOperationService, IDisposable
{
/// <summary>撤销栈上限:超出后丢弃最旧的条目。</summary>
private const int MaxUndoEntries = 30;
/// <summary>进度上报限频(毫秒)。</summary>
private const int ProgressFlushIntervalMs = 50;
/// <summary>JobsChanged 限频(毫秒):进度类变化不按字节风暴式通知。</summary>
private const int JobsChangedThrottleMs = 250;
private const int MaxErrorLength = 2000;
private readonly JobQueue _queue;
private readonly Stack<UndoEntry> _undoStack = new();
private readonly object _undoGate = new();
private int _lastJobsChangedTick;
private bool _disposed;
public FileOperationService()
{
_queue = new JobQueue(ExecuteJobAsync);
_queue.Start();
}
// ------------------------------------------------------------ 队列与状态
public IReadOnlyList<FileOperationJob> Jobs => _queue.Jobs;
public event EventHandler? JobsChanged
{
add => _queue.JobsChanged += value;
remove => _queue.JobsChanged -= value;
}
/// <summary>UI 设置冲突回调;未设置时 Ask 策略按 KeepBoth(保留两者)处理。</summary>
public Func<ConflictInfo, Task<ConflictResolution>>? ConflictResolver { get; set; }
public void Pause(Guid jobId) => _queue.Pause(jobId);
public void Resume(Guid jobId) => _queue.Resume(jobId);
public void Cancel(Guid jobId) => _queue.Cancel(jobId);
public void ClearFinished() => _queue.ClearFinished();
/// <summary>进度类变化按 250ms 限频触发 JobsChanged,避免逐字节通知造成 UI 事件风暴。</summary>
private void NotifyJobsChangedThrottled()
{
var now = Environment.TickCount;
if (unchecked(now - _lastJobsChangedTick) < JobsChangedThrottleMs) return;
_lastJobsChangedTick = now;
_queue.Raise();
}
// ------------------------------------------------------------ 入队
public FileOperationJob EnqueueCopy(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
=> EnqueueTransfer(FileOperationKind.Copy, sources, destination, policy);
public FileOperationJob EnqueueMove(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
=> EnqueueTransfer(FileOperationKind.Move, sources, destination, policy);
public FileOperationJob EnqueueDelete(IReadOnlyList<string> paths, bool permanent = false)
{
var list = NormalizePaths(paths);
var job = new FileOperationJob
{
Kind = permanent ? FileOperationKind.Delete : FileOperationKind.Recycle,
Title = permanent ? $"永久删除 {list.Count} 个项目" : $"删除 {list.Count} 个项目到回收站",
Sources = list,
PermanentDelete = permanent,
Policy = ConflictPolicy.Replace,
// 回收站删除由 Shell 一次调用完成一批,拿不到字节进度,只按文件数上报。
IsIndeterminate = !permanent
};
_queue.Enqueue(job, instantLane: false);
return job;
}
public FileOperationJob EnqueueRename(string path, string newName)
{
var source = PathHelper.TryGetFullPath(path) ?? path;
var job = new FileOperationJob
{
Kind = FileOperationKind.Rename,
Title = $"重命名为“{newName}”",
Sources = [source],
NewName = newName,
Policy = ConflictPolicy.Skip,
IsIndeterminate = true
};
_queue.Enqueue(job, instantLane: true);
return job;
}
public FileOperationJob EnqueueNewFolder(string parentDirectory, string name)
{
var parent = PathHelper.TryGetFullPath(parentDirectory) ?? parentDirectory;
var job = new FileOperationJob
{
Kind = FileOperationKind.NewFolder,
Title = $"新建文件夹“{name}”",
Sources = [parent],
Destination = parent,
NewName = name,
Policy = ConflictPolicy.KeepBoth,
IsIndeterminate = true
};
_queue.Enqueue(job, instantLane: true);
return job;
}
private FileOperationJob EnqueueTransfer(FileOperationKind kind, IReadOnlyList<string> sources, string destination, ConflictPolicy policy)
{
ArgumentNullException.ThrowIfNull(sources);
ArgumentNullException.ThrowIfNull(destination);
var list = NormalizePaths(sources);
var destinationPath = PathHelper.TryGetFullPath(destination) ?? destination;
var verb = kind == FileOperationKind.Copy ? "复制" : "移动";
var job = new FileOperationJob
{
Kind = kind,
Title = list.Count == 1
? $"{verb}“{PathHelper.GetFileName(list[0])}”到 {destinationPath}"
: $"{verb} {list.Count} 个项目到 {destinationPath}",
Sources = list,
Destination = destinationPath,
Policy = policy
};
_queue.Enqueue(job, instantLane: kind == FileOperationKind.Move && IsSameVolumeMove(list, destinationPath));
return job;
}
private static List<string> NormalizePaths(IReadOnlyList<string> paths)
{
var result = new List<string>();
if (paths is null) return result;
foreach (var path in paths)
{
if (string.IsNullOrWhiteSpace(path)) continue;
var full = PathHelper.TryGetFullPath(path) ?? path.Trim();
if (!result.Contains(full, StringComparer.OrdinalIgnoreCase)) result.Add(full);
}
return result;
}
/// <summary>
/// 目标路径解析(入队与执行两处必须一致):
/// - 目标是已存在的目录 / 末尾带分隔符 / 无扩展名 → 视为"放进该目录";
/// - 否则(单个源 + 目标不存在 + 带扩展名)→ 目标即完整目标路径,等价于"复制并改名"。
/// </summary>
private static List<(string Source, string Target)> ResolveTargets(IReadOnlyList<string> sources, string destination)
{
var targets = new List<(string Source, string Target)>();
if (sources.Count == 0) return targets;
var treatAsDirectory = sources.Count > 1
|| PathHelper.DirectoryExists(destination)
|| destination.EndsWith(Path.DirectorySeparatorChar)
|| destination.EndsWith(Path.AltDirectorySeparatorChar)
|| Path.GetExtension(destination).Length == 0;
foreach (var source in sources)
{
targets.Add(treatAsDirectory
? (source, PathHelper.Combine(destination, PathHelper.GetFileName(source)))
: (source, destination));
}
return targets;
}
private static bool IsSameVolumeMove(IReadOnlyList<string> sources, string destination)
{
if (sources.Count == 0) return false;
foreach (var (source, target) in ResolveTargets(sources, destination))
{
if (!PathHelper.SameVolume(source, target)) return false;
}
return true;
}
// ------------------------------------------------------------ 作业执行
private async Task ExecuteJobAsync(JobContext ctx)
{
var job = ctx.Job;
var runner = new JobRunner(this, ctx);
try
{
job.State = ctx.PauseGate.IsSet ? JobState.Running : JobState.Paused;
runner.Notify();
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (!ctx.Cts.IsCancellationRequested)
{
switch (job.Kind)
{
case FileOperationKind.Copy:
await RunTransferAsync(runner, isMove: false).ConfigureAwait(false);
break;
case FileOperationKind.Move:
await RunTransferAsync(runner, isMove: true).ConfigureAwait(false);
break;
case FileOperationKind.Delete:
case FileOperationKind.Recycle:
await RunDeleteAsync(runner).ConfigureAwait(false);
break;
case FileOperationKind.Rename:
await RunRenameAsync(runner).ConfigureAwait(false);
break;
case FileOperationKind.NewFolder:
await RunNewFolderAsync(runner).ConfigureAwait(false);
break;
}
}
}
catch (OperationCanceledException)
{
// 取消是正常流程:已完成的部分保留,不回滚。
}
catch (Exception ex)
{
runner.Warn($"作业异常:{ex.Message}");
}
finally
{
runner.Complete();
if (runner.UndoEntry is { } entry) PushUndo(entry);
}
}
private static async Task RunTransferAsync(JobRunner runner, bool isMove)
{
var job = runner.Job;
var targets = ResolveTargets(job.Sources, job.Destination ?? string.Empty);
var sameVolumeMove = isMove && IsSameVolumeMove(job.Sources, job.Destination ?? string.Empty);
if (sameVolumeMove)
{
// 同卷 Move 是瞬时元数据操作,不产生字节流量:
// 这里刻意不做字节测量,让进度条按"条目数"走,而不是永远停在 0%。
job.TotalBytes = 0;
job.TotalItems = targets.Count;
}
else
{
var (bytes, items) = CopyEngine.Measure(job.Sources, runner.Token, runner.Warn);
job.TotalBytes = bytes;
job.TotalItems = items;
}
job.IsIndeterminate = false;
runner.Notify();
foreach (var (source, target) in targets)
{
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) break;
runner.SetCurrentItem(source);
var outcome = isMove
? await CopyEngine.MoveEntryAsync(source, target, runner).ConfigureAwait(false)
: await CopyEngine.CopyEntryAsync(source, target, runner).ConfigureAwait(false);
if (outcome == EntryOutcome.Success) runner.CountSucceeded();
else if (outcome == EntryOutcome.Cancelled) break;
}
if (isMove && runner.MoveRecords.Count > 0)
{
runner.SetUndoEntry(new UndoEntry
{
Description = $"撤销 移动 {job.Sources.Count} 个项目到 {job.Destination}",
Kind = FileOperationKind.Move,
Moves = [.. runner.MoveRecords]
});
}
}
private static async Task RunDeleteAsync(JobRunner runner)
{
var job = runner.Job;
if (job.Sources.Count == 0) return;
if (job.PermanentDelete)
{
var (_, deleteItems) = CopyEngine.Measure(job.Sources, runner.Token, runner.Warn);
job.TotalBytes = 0;
job.TotalItems = deleteItems;
job.IsIndeterminate = false;
runner.Notify();
foreach (var path in job.Sources)
{
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) break;
var outcome = await CopyEngine.DeletePermanentAsync(path, runner, countAsItem: true).ConfigureAwait(false);
if (outcome == EntryOutcome.Success) runner.CountSucceeded();
else if (outcome == EntryOutcome.Cancelled) break;
}
// 永久删除无法撤销:不产生 UndoEntry(避免 Ctrl+Z 出现"撤销后什么都没发生"的空操作)。
return;
}
job.TotalBytes = 0;
job.TotalItems = job.Sources.Count;
job.IsIndeterminate = true;
runner.Notify();
// 1) 删除前对每个卷的 <卷>:\$Recycle.Bin\<SID>\ 做 $I 快照(撤销定位靠前后差集)。
var before = RecycleBinLocator.CaptureState(job.Sources);
runner.SetCurrentItem(job.Sources.Count == 1 ? job.Sources[0] : $"{job.Sources.Count} 个项目");
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) return;
// 2) 一次 Shell 调用完成一批(FOF_ALLOWUNDO = 进回收站而不是永久删除)。
var (success, aborted, code) = await RecycleBinLocator.DeleteToRecycleBinAsync(job.Sources).ConfigureAwait(false);
if (!success)
{
var reason = aborted ? "操作被 Shell 中止。" : RecycleBinLocator.DescribeResult(code);
runner.Warn($"删除到回收站失败:{reason}");
runner.CountFailed(job.Sources.Count);
return;
}
var deletedCount = job.Sources.Count(p => !PathHelper.Exists(p));
job.CompletedItems = deletedCount;
runner.CountSucceeded(Math.Max(deletedCount, 0));
foreach (var path in job.Sources.Where(PathHelper.Exists))
runner.Warn($"Shell 报告成功但文件仍然存在:{path}");
// 3) 差集定位回收站内新增的 $I/$R,填进 UndoEntry.Deleted(解析失败只警告,绝不崩溃)。
var (items, diagnostics) = RecycleBinLocator.ResolveDeletedItems(job.Sources, before);
foreach (var diagnostic in diagnostics) runner.Warn(diagnostic);
if (items.Count > 0 && items.All(i => string.IsNullOrEmpty(i.RecyclePath)))
{
runner.Warn("回收站不可用(Shell 无法在 <卷>:\\$Recycle.Bin 下建立 $I/$R 记录),本次删除实际为永久删除,无法撤销。");
}
if (items.Count > 0)
{
runner.SetUndoEntry(new UndoEntry
{
Description = $"撤销 删除 {deletedCount} 个项目",
Kind = FileOperationKind.Recycle,
Moves = [],
Deleted = items
});
}
}
private static async Task RunRenameAsync(JobRunner runner)
{
var job = runner.Job;
job.TotalBytes = 0;
job.TotalItems = 1;
job.IsIndeterminate = true;
runner.Notify();
var source = job.Sources.FirstOrDefault();
if (source is null)
{
runner.Warn("没有指定要重命名的路径。");
runner.CountFailed();
return;
}
var newName = job.NewName ?? string.Empty;
if (!PathHelper.TryValidateFileName(newName, out var validationError))
{
runner.Warn(validationError!);
runner.CountFailed();
return;
}
var target = PathHelper.Combine(PathHelper.GetDirectoryName(source), newName);
if (string.Equals(source.TrimEnd('\\'), target.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
{
// 名称没变化:直接算成功,不要动文件。
job.CompletedItems = 1;
runner.CountSucceeded();
return;
}
if (PathHelper.Exists(target))
{
runner.Warn($"目标名称已存在,重命名不会覆盖:{target}");
runner.CountFailed();
return;
}
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) return;
var outcome = await CopyEngine.MoveDirectAsync(source, target, runner).ConfigureAwait(false);
if (outcome == EntryOutcome.Success)
{
job.CompletedItems = 1;
runner.CountSucceeded();
runner.SetUndoEntry(new UndoEntry
{
Description = $"撤销 重命名“{newName}”",
Kind = FileOperationKind.Rename,
Moves = [.. runner.MoveRecords]
});
}
else if (outcome != EntryOutcome.Cancelled)
{
runner.CountFailed();
}
}
private static async Task RunNewFolderAsync(JobRunner runner)
{
var job = runner.Job;
job.TotalBytes = 0;
job.TotalItems = 1;
job.IsIndeterminate = true;
runner.Notify();
var parent = job.Destination ?? job.Sources.FirstOrDefault();
if (parent is null)
{
runner.Warn("没有指定父目录。");
runner.CountFailed();
return;
}
var name = job.NewName ?? string.Empty;
if (!PathHelper.TryValidateFileName(name, out var validationError))
{
runner.Warn(validationError!);
runner.CountFailed();
return;
}
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) return;
try
{
Directory.CreateDirectory(PathHelper.ToExtended(parent));
// 重名时自动避让:"新建文件夹 (2)"。
var target = PathHelper.MakeUniquePath(PathHelper.Combine(parent, name));
Directory.CreateDirectory(PathHelper.ToExtended(target));
job.CurrentItem = target;
job.CompletedItems = 1;
runner.CountSucceeded();
runner.Warn($"已创建:{target}");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
runner.Warn($"新建文件夹失败:{ex.Message}");
runner.CountFailed();
}
}
// ------------------------------------------------------------ 撤销
public bool CanUndo
{
get
{
lock (_undoGate) return _undoStack.Count > 0;
}
}
public string? UndoDescription
{
get
{
lock (_undoGate) return _undoStack.Count > 0 ? _undoStack.Peek().Description : null;
}
}
public event EventHandler? UndoStackChanged;
/// <summary>
/// 一步撤销栈顶作业:Move/Rename 逐项搬回原位置,Recycle 把回收站里的 $R 搬回原始路径。
/// 所有 IO 都在线程池上执行(Task.Run + 异步 IO),UI await 即可,绝不会阻塞 UI 线程。
/// </summary>
public Task<OperationResult> UndoAsync(CancellationToken cancellationToken = default)
=> Task.Run(() => UndoCoreAsync(cancellationToken), CancellationToken.None);
private async Task<OperationResult> UndoCoreAsync(CancellationToken cancellationToken)
{
UndoEntry? entry;
lock (_undoGate)
{
if (_undoStack.Count == 0) return new OperationResult(false, 0, 0, 0, "没有可撤销的操作。");
entry = _undoStack.Pop();
}
UndoStackChanged?.Invoke(this, EventArgs.Empty);
var sink = new UndoSink(cancellationToken);
var succeeded = 0;
var failed = 0;
// 1) Move / Rename:把 From(当前位置)搬回 To(原位置)。同卷时是瞬时 File.Move。
foreach (var (from, to) in entry.Moves)
{
if (cancellationToken.IsCancellationRequested) break;
if (!PathHelper.Exists(from))
{
sink.Warn($"撤销失败,找不到待搬回的项目:{from}");
failed++;
continue;
}
PathHelper.EnsureParentDirectory(to);
// 撤销时遇到冲突按 KeepBoth:绝不覆盖用户现有数据。
var target = PathHelper.Exists(to) ? PathHelper.MakeUniquePath(to) : to;
var outcome = await CopyEngine.MoveDirectAsync(from, target, sink).ConfigureAwait(false);
if (outcome == EntryOutcome.Success) succeeded++;
else failed++;
}
// 2) Recycle:把回收站里的 $R 数据搬回原始路径(目标父目录不存在时先创建)。
foreach (var (recyclePath, originalPath) in entry.Deleted)
{
if (cancellationToken.IsCancellationRequested) break;
if (string.IsNullOrEmpty(recyclePath) || !PathHelper.Exists(recyclePath))
{
sink.Warn($"无法还原(回收站条目缺失或 $I/$R 解析失败):{originalPath}");
failed++;
continue;
}
PathHelper.EnsureParentDirectory(originalPath);
var target = PathHelper.Exists(originalPath) ? PathHelper.MakeUniquePath(originalPath) : originalPath;
var outcome = await CopyEngine.MoveDirectAsync(recyclePath, target, sink).ConfigureAwait(false);
if (outcome == EntryOutcome.Success)
{
succeeded++;
RemoveIndexFile(recyclePath);
}
else
{
failed++;
}
}
var errors = sink.Errors.Count > 0 ? string.Join(Environment.NewLine, sink.Errors) : null;
return new OperationResult(failed == 0 && succeeded > 0, succeeded, failed, 0, errors);
}
/// <summary>还原成功后顺手删掉对应的 $I 索引,避免回收站里留下指向不存在数据的死条目。</summary>
private static void RemoveIndexFile(string recycleDataPath)
{
try
{
var indexPath = RecycleBinLocator.GetIndexPathFromDataPath(recycleDataPath);
if (indexPath is not null && PathHelper.FileExists(indexPath))
{
PathHelper.ClearReadOnly(indexPath);
File.Delete(PathHelper.ToExtended(indexPath));
}
}
catch (Exception)
{
// 元数据清理失败不影响还原结果。
}
}
private void PushUndo(UndoEntry entry)
{
if (entry.Moves.Count == 0 && entry.Deleted.Count == 0) return;
lock (_undoGate)
{
_undoStack.Push(entry);
if (_undoStack.Count > MaxUndoEntries)
{
// Stack 的枚举顺序是"栈顶在前",取前 N 条即保留最新的 N 条。
var kept = _undoStack.Take(MaxUndoEntries).Reverse().ToArray();
_undoStack.Clear();
foreach (var item in kept) _undoStack.Push(item);
}
}
UndoStackChanged?.Invoke(this, EventArgs.Empty);
}
// ------------------------------------------------------------ 测量
public Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
{
var list = paths is null || paths.Count == 0 ? [] : NormalizePaths(paths);
return Task.Run(() => CopyEngine.Measure(list, cancellationToken), CancellationToken.None);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_queue.Dispose();
}
// ------------------------------------------------------------ 撤销用的空实现 sink
private sealed class UndoSink : IJobSink
{
public UndoSink(CancellationToken token) => Token = token;
public List<string> Errors { get; } = [];
public CancellationToken Token { get; }
public bool IsCancellationRequested => Token.IsCancellationRequested;
public Task WaitIfPausedAsync() => Task.CompletedTask;
public void AddBytes(long delta) { }
public void AddCompletedItems(int delta) { }
public void SetCurrentItem(string path) { }
public void Warn(string message) => Errors.Add(message);
public void AddFailed(string path, string reason) => Errors.Add($"{path}:{reason}");
public void AddSkipped(int delta = 1) { }
public void RecordMoveForUndo(string newPath, string originalPath) { }
public Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info) => Task.FromResult(ConflictResolution.KeepBoth);
public void RequestCancel() { }
}
// ------------------------------------------------------------ 单个作业的运行器
/// <summary>
/// 单个作业的执行上下文:负责限频进度上报、统计成功/失败/跳过、收集撤销记录与诊断信息。
/// </summary>
private sealed class JobRunner : IJobSink
{
private readonly FileOperationService _owner;
private readonly JobContext _ctx;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private readonly List<string> _diagnostics = [];
private readonly List<(string From, string To)> _moveRecords = [];
private long _pendingBytes;
private int _pendingItems;
private string? _pendingCurrentItem;
private long _lastFlushMs;
private long _lastBytesFlushMs;
private long _lastRaiseAllMs;
private double _speed;
private int _succeeded;
private int _failed;
private int _skipped;
private ConflictResolution? _applyToAll;
public JobRunner(FileOperationService owner, JobContext ctx)
{
_owner = owner;
_ctx = ctx;
}
public FileOperationJob Job => _ctx.Job;
public CancellationToken Token => _ctx.Cts.Token;
public bool IsCancellationRequested => _ctx.Cts.IsCancellationRequested;
public List<(string From, string To)> MoveRecords => _moveRecords;
public UndoEntry? UndoEntry { get; private set; }
public void SetUndoEntry(UndoEntry entry) => UndoEntry = entry;
/// <summary>立即刷新一次进度并通知 UI(作业开始、阶段切换、结束等关键时刻)。</summary>
public void Notify() => FlushProgress(force: true);
public async Task WaitIfPausedAsync()
{
var gate = _ctx.PauseGate;
while (!gate.IsSet)
{
if (IsCancellationRequested) return;
await gate.WaitAsync().ConfigureAwait(false);
}
}
public void AddBytes(long delta)
{
if (delta == 0) return;
Interlocked.Add(ref _pendingBytes, delta);
FlushIfDue();
}
public void AddCompletedItems(int delta)
{
if (delta == 0) return;
Interlocked.Add(ref _pendingItems, delta);
FlushIfDue();
}
public void SetCurrentItem(string path)
{
Interlocked.Exchange(ref _pendingCurrentItem, path);
FlushIfDue();
}
public void AddSkipped(int delta = 1)
{
_skipped += delta;
AddCompletedItems(delta);
}
public void AddFailed(string path, string reason)
{
_failed++;
Warn($"{path}:{reason}");
}
public void Warn(string message)
{
if (string.IsNullOrWhiteSpace(message)) return;
_diagnostics.Add(message);
}
public void CountSucceeded(int delta = 1) => _succeeded += delta;
public void CountFailed(int delta = 1) => _failed += delta;
public void RecordMoveForUndo(string newPath, string originalPath) => _moveRecords.Add((newPath, originalPath));
public void RequestCancel()
{
Warn("已按用户选择取消后续操作。");
_ctx.Cts.Cancel();
_ctx.PauseGate.Set();
}
public async Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info)
{
// "为后续所有冲突执行相同操作":一次勾选,后续冲突不再打扰 UI。
if (_applyToAll is { } applied) return applied;
var resolver = _owner.ConflictResolver;
if (Job.Policy == ConflictPolicy.Ask && resolver is not null)
{
try
{
// 回调是 await 的:期间作业状态保持 Running(或 Paused),当前项挂起,
// 但 UI 线程完全自由(回调由 UI 自己 marshal 回 UI 线程弹对话框)。
var resolution = await resolver(info).ConfigureAwait(false);
if (info.ApplyToAll) _applyToAll = resolution;
return resolution;
}
catch (Exception ex)
{
Warn($"冲突回调异常,按“保留两者”处理:{ex.Message}");
return ConflictResolution.KeepBoth;
}
}
return Job.Policy switch
{
ConflictPolicy.Replace or ConflictPolicy.Merge => ConflictResolution.Replace,
ConflictPolicy.Skip => ConflictResolution.Skip,
_ => ConflictResolution.KeepBoth // Ask 但没有 UI 回调 → 保留两者
};
}
private void FlushIfDue()
{
if (_clock.ElapsedMilliseconds - _lastFlushMs >= ProgressFlushIntervalMs) FlushProgress(force: false);
}
/// <summary>
/// 限频进度刷新:
/// - 数值属性(字节 / 条目 / 当前项 / 速度)每 50ms 写一次,各自只触发一个 PropertyChanged;
/// - 计算属性(Progress / Eta / CanPause…)每 250ms 通过一次 RaiseAll 刷新。
/// 于是 2000 个文件的复制只产生约 20 次/秒的 UI 通知,而不是"每个文件一次"的事件风暴。
/// </summary>
private void FlushProgress(bool force)
{
var now = _clock.ElapsedMilliseconds;
var bytes = Interlocked.Exchange(ref _pendingBytes, 0);
var items = Interlocked.Exchange(ref _pendingItems, 0);
var current = Interlocked.Exchange(ref _pendingCurrentItem, null);
if (bytes != 0)
{
Job.CompletedBytes += bytes;
var elapsed = Math.Max(1, now - _lastBytesFlushMs);
var instant = bytes * 1000.0 / elapsed;
_speed = _speed <= 1 ? instant : (_speed * 0.7) + (instant * 0.3);
Job.BytesPerSecond = _speed;
_lastBytesFlushMs = now;
}
if (items != 0) Job.CompletedItems += items;
if (current is not null) Job.CurrentItem = current;
_lastFlushMs = now;
if (force || now - _lastRaiseAllMs >= JobsChangedThrottleMs)
{
_lastRaiseAllMs = now;
// RaiseAll 让绑定 Progress / Eta 的 UI 也能刷新(普通 Set 只通知单个属性)。
Job.RaiseAll();
_owner.NotifyJobsChangedThrottled();
}
}
public void Complete()
{
FlushProgress(force: true);
var job = Job;
var cancelled = IsCancellationRequested;
if (cancelled)
{
var bytes = job.TotalBytes > 0
? $",{FormatBytes(job.CompletedBytes)}/{FormatBytes(job.TotalBytes)}"
: string.Empty;
_diagnostics.Insert(0, $"已取消,已完成 {job.CompletedItems}/{job.TotalItems} 项{bytes}。");
job.State = JobState.Cancelled;
}
else if (_failed > 0)
{
job.State = _succeeded > 0 ? JobState.CompletedWithErrors : JobState.Failed;
}
else
{
job.State = JobState.Completed;
}
if (_skipped > 0) _diagnostics.Add($"已跳过 {_skipped} 个项目。");
job.Error = BuildDiagnostics();
Job.RaiseAll();
_owner._queue.Raise();
}
private string? BuildDiagnostics()
{
if (_diagnostics.Count == 0) return null;
var builder = new StringBuilder();
var included = 0;
foreach (var line in _diagnostics)
{
if (builder.Length + line.Length + 1 > MaxErrorLength)
{
builder.Append(Environment.NewLine).Append($"…(其余 {_diagnostics.Count - included} 条已省略)");
break;
}
if (builder.Length > 0) builder.Append(Environment.NewLine);
builder.Append(line);
included++;
}
return builder.ToString();
}
internal static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB"];
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return $"{value:0.##}{units[unit]}";
}
}
}