923 lines
33 KiB
C#
923 lines
33 KiB
C#
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]}";
|
||
}
|
||
}
|
||
}
|