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 了):保留,不算失败。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
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]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
public enum FileOperationKind
|
||||
{
|
||||
Copy,
|
||||
Move,
|
||||
Delete,
|
||||
Recycle,
|
||||
Rename,
|
||||
NewFolder
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
{
|
||||
Queued,
|
||||
Running,
|
||||
Paused,
|
||||
Completed,
|
||||
CompletedWithErrors,
|
||||
Cancelled,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum ConflictPolicy
|
||||
{
|
||||
/// <summary>交给 UI 决定(ConflictResolver)。</summary>
|
||||
Ask,
|
||||
Replace,
|
||||
Skip,
|
||||
KeepBoth,
|
||||
Merge
|
||||
}
|
||||
|
||||
public enum ConflictResolution
|
||||
{
|
||||
Replace,
|
||||
Skip,
|
||||
KeepBoth,
|
||||
Cancel
|
||||
}
|
||||
|
||||
public sealed class ConflictInfo
|
||||
{
|
||||
public required string SourcePath { get; init; }
|
||||
public required string DestinationPath { get; init; }
|
||||
public bool SourceIsDirectory { get; init; }
|
||||
public long SourceSize { get; init; }
|
||||
public long DestinationSize { get; init; }
|
||||
public DateTime SourceModifiedUtc { get; init; }
|
||||
public DateTime DestinationModifiedUtc { get; init; }
|
||||
/// <summary>true 表示用户勾选了"为后续所有冲突执行相同操作"。</summary>
|
||||
public bool ApplyToAll { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>队列里的一个作业:可暂停/继续/取消,进度实时上报(含速度与剩余时间)。</summary>
|
||||
public sealed class FileOperationJob : INotifyPropertyChanged
|
||||
{
|
||||
private JobState _state = JobState.Queued;
|
||||
private int _completedItems;
|
||||
private long _completedBytes;
|
||||
private string? _currentItem;
|
||||
private string? _error;
|
||||
private double _bytesPerSecond;
|
||||
private bool _isIndeterminate;
|
||||
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
public required FileOperationKind Kind { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required IReadOnlyList<string> Sources { get; init; }
|
||||
public string? Destination { get; init; }
|
||||
public ConflictPolicy Policy { get; init; } = ConflictPolicy.Ask;
|
||||
public bool PermanentDelete { get; init; }
|
||||
public string? NewName { get; init; }
|
||||
public DateTime StartedUtc { get; } = DateTime.UtcNow;
|
||||
|
||||
public JobState State
|
||||
{
|
||||
get => _state;
|
||||
set => Set(ref _state, value);
|
||||
}
|
||||
|
||||
public int TotalItems { get; set; }
|
||||
public int CompletedItems { get => _completedItems; set => Set(ref _completedItems, value); }
|
||||
public long TotalBytes { get; set; }
|
||||
public long CompletedBytes { get => _completedBytes; set => Set(ref _completedBytes, value); }
|
||||
public double BytesPerSecond { get => _bytesPerSecond; set => Set(ref _bytesPerSecond, value); }
|
||||
public string? CurrentItem { get => _currentItem; set => Set(ref _currentItem, value); }
|
||||
public string? Error { get => _error; set => Set(ref _error, value); }
|
||||
|
||||
/// <summary>大小为 0 的作业(纯重命名等)显示旋转指示器。</summary>
|
||||
public bool IsIndeterminate { get => _isIndeterminate; set => Set(ref _isIndeterminate, value); }
|
||||
|
||||
public double Progress => TotalBytes > 0
|
||||
? Math.Clamp((double)CompletedBytes / TotalBytes, 0, 1)
|
||||
: (TotalItems > 0 ? Math.Clamp((double)CompletedItems / TotalItems, 0, 1) : 0);
|
||||
|
||||
public TimeSpan? Eta => BytesPerSecond > 1 && TotalBytes > CompletedBytes
|
||||
? TimeSpan.FromSeconds((TotalBytes - CompletedBytes) / BytesPerSecond)
|
||||
: null;
|
||||
|
||||
public bool CanPause => State is JobState.Running or JobState.Paused;
|
||||
public bool CanCancel => State is JobState.Queued or JobState.Running or JobState.Paused;
|
||||
public bool IsFinished => State is JobState.Completed or JobState.CompletedWithErrors or JobState.Cancelled or JobState.Failed;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
internal void RaiseAll()
|
||||
{
|
||||
foreach (var name in new[] { nameof(State), nameof(CompletedItems), nameof(CompletedBytes), nameof(BytesPerSecond),
|
||||
nameof(CurrentItem), nameof(Error), nameof(Progress), nameof(Eta),
|
||||
nameof(CanPause), nameof(CanCancel), nameof(IsFinished), nameof(IsIndeterminate) })
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
private void Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UndoEntry
|
||||
{
|
||||
public required string Description { get; init; }
|
||||
public required FileOperationKind Kind { get; init; }
|
||||
/// <summary>(原路径, 现路径) 对;撤销即反向搬运。</summary>
|
||||
public required List<(string From, string To)> Moves { get; init; }
|
||||
/// <summary>删除操作记录:回收站里的 $R 文件路径 → 原始路径。</summary>
|
||||
public List<(string RecyclePath, string OriginalPath)> Deleted { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record OperationResult(bool Success, int Succeeded, int Failed, int Skipped, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// 文件操作引擎:所有操作进入统一队列串行/并行执行,UI 永不阻塞。
|
||||
/// 支持暂停、继续、取消、冲突策略、错误重试,以及一步撤销(Ctrl+Z)。
|
||||
/// </summary>
|
||||
public interface IFileOperationService
|
||||
{
|
||||
IReadOnlyList<FileOperationJob> Jobs { get; }
|
||||
event EventHandler? JobsChanged;
|
||||
|
||||
/// <summary>UI 设置此回调以弹出冲突对话框;未设置时按 KeepBoth 处理。</summary>
|
||||
Func<ConflictInfo, Task<ConflictResolution>>? ConflictResolver { get; set; }
|
||||
|
||||
FileOperationJob EnqueueCopy(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask);
|
||||
FileOperationJob EnqueueMove(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask);
|
||||
FileOperationJob EnqueueDelete(IReadOnlyList<string> paths, bool permanent = false);
|
||||
FileOperationJob EnqueueRename(string path, string newName);
|
||||
FileOperationJob EnqueueNewFolder(string parentDirectory, string name);
|
||||
|
||||
void Pause(Guid jobId);
|
||||
void Resume(Guid jobId);
|
||||
void Cancel(Guid jobId);
|
||||
void ClearFinished();
|
||||
|
||||
bool CanUndo { get; }
|
||||
string? UndoDescription { get; }
|
||||
event EventHandler? UndoStackChanged;
|
||||
Task<OperationResult> UndoAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>计算源集合的总大小与条目数(后台执行,用于进度条与冲突提示)。</summary>
|
||||
Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>
|
||||
/// 可异步等待的自动/手动复位事件:用于"暂停"语义。
|
||||
/// 关键点:<see cref="TaskCompletionSource"/> 必须带 RunContinuationsAsynchronously,
|
||||
/// 否则 <see cref="Set"/> 会在调用线程(通常是 UI 线程)上同步执行拷贝循环的续体,
|
||||
/// 从而把磁盘 IO 拖回 UI 线程。
|
||||
/// </summary>
|
||||
internal sealed class AsyncManualResetEvent
|
||||
{
|
||||
private TaskCompletionSource _tcs;
|
||||
|
||||
public AsyncManualResetEvent(bool initialState = true) => _tcs = Create(initialState);
|
||||
|
||||
public bool IsSet => _tcs.Task.IsCompleted;
|
||||
|
||||
public Task WaitAsync() => _tcs.Task;
|
||||
|
||||
public void Set() => _tcs.TrySetResult();
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = _tcs;
|
||||
if (!current.Task.IsCompleted) return;
|
||||
var fresh = Create(false);
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _tcs, fresh, current), current)) return;
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskCompletionSource Create(bool completed)
|
||||
{
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (completed) tcs.SetResult();
|
||||
return tcs;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>引擎内部对一个作业的控制块:取消令牌 + 暂停闸门 + 调度车道标记。</summary>
|
||||
internal sealed class JobContext
|
||||
{
|
||||
public JobContext(FileOperationJob job) => Job = job;
|
||||
|
||||
public FileOperationJob Job { get; }
|
||||
|
||||
public CancellationTokenSource Cts { get; } = new();
|
||||
|
||||
/// <summary>初始为 Set(未暂停)。Reset 即暂停,Set 即继续。</summary>
|
||||
public AsyncManualResetEvent PauseGate { get; } = new(initialState: true);
|
||||
|
||||
/// <summary>
|
||||
/// true = 走"瞬时车道":同卷 Move / 重命名 / 新建文件夹这类不搬字节的操作,
|
||||
/// 可以和其他作业并行,不必排队等大拷贝。
|
||||
/// </summary>
|
||||
public bool InstantLane { get; set; }
|
||||
|
||||
public bool Started { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 作业队列:后台 worker 严格按入队顺序调度。
|
||||
/// 默认串行(避免多任务同时读盘造成磁盘抖动、拖慢整体吞吐),
|
||||
/// 但"瞬时操作"(同卷 Move / 重命名 / 新建文件夹)允许并行,因为它们只做元数据操作。
|
||||
/// </summary>
|
||||
internal sealed class JobQueue : IDisposable
|
||||
{
|
||||
/// <summary>瞬时车道最大并行度,防止一次排入上千个瞬时作业时线程爆炸。</summary>
|
||||
private const int MaxInstantParallelism = 4;
|
||||
|
||||
private readonly Func<JobContext, Task> _executor;
|
||||
private readonly ObservableCollection<FileOperationJob> _jobs = [];
|
||||
private readonly List<JobContext> _pending = [];
|
||||
private readonly Dictionary<Guid, JobContext> _contexts = [];
|
||||
private readonly HashSet<Task> _instantTasks = [];
|
||||
private readonly SemaphoreSlim _signal = new(0);
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private readonly object _gate = new();
|
||||
private Task? _dispatcher;
|
||||
private bool _disposed;
|
||||
|
||||
public JobQueue(Func<JobContext, Task> executor) => _executor = executor;
|
||||
|
||||
public ObservableCollection<FileOperationJob> Jobs => _jobs;
|
||||
|
||||
public event EventHandler? JobsChanged;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_dispatcher ??= Task.Run(DispatcherLoopAsync);
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue(FileOperationJob job, bool instantLane)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
var ctx = new JobContext(job) { InstantLane = instantLane };
|
||||
_contexts[job.Id] = ctx;
|
||||
_pending.Add(ctx);
|
||||
_jobs.Add(job);
|
||||
}
|
||||
|
||||
_signal.Release();
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void Pause(Guid jobId)
|
||||
{
|
||||
JobContext? ctx;
|
||||
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||
if (ctx is null) return;
|
||||
|
||||
// 只有已经在跑的作业才能暂停(Queued 状态的作业由模型定义为 CanPause=false)。
|
||||
if (!ctx.Job.CanPause) return;
|
||||
|
||||
ctx.PauseGate.Reset();
|
||||
if (ctx.Job.State == JobState.Running) ctx.Job.State = JobState.Paused;
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void Resume(Guid jobId)
|
||||
{
|
||||
JobContext? ctx;
|
||||
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||
if (ctx is null || ctx.Job.IsFinished) return;
|
||||
|
||||
ctx.PauseGate.Set();
|
||||
if (ctx.Job.State == JobState.Paused) ctx.Job.State = JobState.Running;
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void Cancel(Guid jobId)
|
||||
{
|
||||
JobContext? ctx;
|
||||
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||
if (ctx is null || ctx.Job.IsFinished) return;
|
||||
|
||||
ctx.Cts.Cancel();
|
||||
|
||||
// 让处于暂停中的拷贝循环立刻被唤醒,从而在同一粒度内观察到取消。
|
||||
ctx.PauseGate.Set();
|
||||
|
||||
if (ctx.Job.State == JobState.Queued)
|
||||
{
|
||||
// 还没开始跑:直接落终态,调度循环只挑 Queued 的作业,因此会自然跳过。
|
||||
ctx.Job.State = JobState.Cancelled;
|
||||
ctx.Job.Error = AppendLine(ctx.Job.Error, "已取消(尚未开始)。");
|
||||
}
|
||||
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void ClearFinished()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
for (var i = _jobs.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!_jobs[i].IsFinished) continue;
|
||||
_contexts.Remove(_jobs[i].Id);
|
||||
_jobs.RemoveAt(i);
|
||||
}
|
||||
|
||||
_pending.RemoveAll(c => c.Job.IsFinished);
|
||||
}
|
||||
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void Raise() => JobsChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
internal static string AppendLine(string? existing, string line)
|
||||
=> string.IsNullOrEmpty(existing) ? line : existing + Environment.NewLine + line;
|
||||
|
||||
private async Task DispatcherLoopAsync()
|
||||
{
|
||||
var token = _shutdown.Token;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _signal.WaitAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
JobContext? ctx;
|
||||
lock (_gate)
|
||||
{
|
||||
ctx = _pending.FirstOrDefault(c => c.Job.State == JobState.Queued);
|
||||
if (ctx is not null && ctx.InstantLane && _instantTasks.Count >= MaxInstantParallelism)
|
||||
ctx = null; // 车道满了:本轮不挑,等下一轮(等价于退化成串行)
|
||||
if (ctx is not null) ctx.Started = true;
|
||||
}
|
||||
|
||||
if (ctx is null) break;
|
||||
|
||||
if (ctx.InstantLane)
|
||||
{
|
||||
var task = Task.Run(() => RunSafelyAsync(ctx), CancellationToken.None);
|
||||
lock (_gate) _instantTasks.Add(task);
|
||||
_ = task.ContinueWith(
|
||||
t =>
|
||||
{
|
||||
lock (_gate) _instantTasks.Remove(t);
|
||||
_signal.Release(); // 唤醒调度循环,检查是否还有排队的作业
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunSafelyAsync(ctx).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lock (_gate) _pending.Remove(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunSafelyAsync(JobContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _executor(ctx).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (!ctx.Job.IsFinished) ctx.Job.State = JobState.Cancelled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ctx.Job.Error = AppendLine(ctx.Job.Error, ex.Message);
|
||||
if (!ctx.Job.IsFinished) ctx.Job.State = JobState.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Raise();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
_shutdown.Cancel();
|
||||
try { _signal.Release(); } catch (Exception) { /* 忽略 */ }
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var ctx in _contexts.Values)
|
||||
{
|
||||
try { ctx.Cts.Cancel(); } catch (Exception) { /* 忽略 */ }
|
||||
ctx.Cts.Dispose();
|
||||
}
|
||||
|
||||
_contexts.Clear();
|
||||
}
|
||||
|
||||
_signal.Dispose();
|
||||
_shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>
|
||||
/// 路径与文件系统元数据工具。
|
||||
///
|
||||
/// 设计约定(整个 Operations 引擎统一遵守):
|
||||
/// 1. 引擎内部、作业描述、UndoEntry 里保存的都是"普通路径"(不带 \\?\ 前缀),
|
||||
/// 只有真正触达 BCL / Win32 IO 的那一刻才通过 <see cref="ToExtended"/> 转换,
|
||||
/// 避免 \\?\ 前缀泄漏到 UI 显示、$I 解析、Shell API 调用里(Shell API 不接受前缀)。
|
||||
/// 2. 所有拼接都用 <see cref="Combine"/>(手工拼分隔符),不用 Path.Combine 后直接丢给 API,
|
||||
/// 以保证超长路径(>260)在所有环节都能正确走到 \\?\ 分支。
|
||||
/// 3. 所有 IO 调用一律走 <see cref="ToExtended"/>,NET8 虽然自身也会兜底长路径,
|
||||
/// 但统一处理后行为可预期(尤其是 UNC:\\server\share → \\?\UNC\server\share)。
|
||||
/// </summary>
|
||||
internal static partial class PathHelper
|
||||
{
|
||||
internal const string ExtendedPrefix = @"\\?\";
|
||||
internal const string ExtendedUncPrefix = @"\\?\UNC\";
|
||||
|
||||
[GeneratedRegex(@"^(.*) \((\d+)\)$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex CopySuffixRegex();
|
||||
|
||||
/// <summary>安全取全路径;非法路径(空、含非法字符、超长到无法规范化)返回 null 而不抛。</summary>
|
||||
internal static string? TryGetFullPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
try
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>转成 Win32 长路径形式(\\?\ 或 \\?\UNC\)。已是前缀形式则原样返回。</summary>
|
||||
internal static string ToExtended(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
|
||||
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) ||
|
||||
path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||
return path;
|
||||
|
||||
string full;
|
||||
try
|
||||
{
|
||||
full = Path.GetFullPath(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 无法规范化:原样返回,让后续 API 抛出可读异常,由重试/失败统计兜住。
|
||||
return path;
|
||||
}
|
||||
|
||||
if (full.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) ||
|
||||
full.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||
return full;
|
||||
|
||||
// UNC:\\server\share\x → \\?\UNC\server\share\x
|
||||
if (full.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
return ExtendedUncPrefix + full[2..];
|
||||
|
||||
return ExtendedPrefix + full;
|
||||
}
|
||||
|
||||
/// <summary>去掉长路径前缀,得到可显示/可交给 Shell API 的普通路径。</summary>
|
||||
internal static string StripExtended(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal))
|
||||
return @"\\" + path[ExtendedUncPrefix.Length..];
|
||||
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||
return path[ExtendedPrefix.Length..];
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>手工拼接子路径(不依赖 Path.Combine 的根路径语义)。</summary>
|
||||
internal static string Combine(string directory, string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directory)) return name;
|
||||
var last = directory[^1];
|
||||
return last is '\\' or '/' ? directory + name : directory + Path.DirectorySeparatorChar + name;
|
||||
}
|
||||
|
||||
/// <summary>取最后一段名字(同时兼容带/不带 \\?\ 前缀)。</summary>
|
||||
internal static string GetFileName(string path)
|
||||
{
|
||||
var p = StripExtended(path);
|
||||
if (string.IsNullOrEmpty(p)) return p;
|
||||
var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
if (trimmed.Length == 0) return p; // 卷根,如 "E:\"
|
||||
var idx = trimmed.LastIndexOfAny(['\\', '/']);
|
||||
return idx < 0 ? trimmed : trimmed[(idx + 1)..];
|
||||
}
|
||||
|
||||
/// <summary>取父目录;已是卷根时返回卷根本身(不返回 null,方便调用方继续拼接)。</summary>
|
||||
internal static string GetDirectoryName(string path)
|
||||
{
|
||||
var p = StripExtended(path);
|
||||
var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var idx = trimmed.LastIndexOfAny(['\\', '/']);
|
||||
if (idx < 0) return p;
|
||||
var parent = trimmed[..idx];
|
||||
// "E:" → "E:\"
|
||||
if (parent.Length == 2 && parent[1] == ':') return parent + Path.DirectorySeparatorChar;
|
||||
if (parent.Length == 0) return @"\";
|
||||
return parent;
|
||||
}
|
||||
|
||||
/// <summary>取卷根(用于同卷判定);UNC 时返回 \\server\share\。</summary>
|
||||
internal static string GetVolumeRoot(string path)
|
||||
{
|
||||
var full = TryGetFullPath(path) ?? StripExtended(path);
|
||||
var root = Path.GetPathRoot(full);
|
||||
return string.IsNullOrEmpty(root) ? full : root;
|
||||
}
|
||||
|
||||
/// <summary>是否同一卷(同卷 Move 才能走瞬时的 File.Move/Directory.Move)。</summary>
|
||||
internal static bool SameVolume(string a, string b)
|
||||
=> string.Equals(GetVolumeRoot(a), GetVolumeRoot(b), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
internal static bool FileExists(string path)
|
||||
{
|
||||
try { return File.Exists(ToExtended(path)); } catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
internal static bool DirectoryExists(string path)
|
||||
{
|
||||
try { return Directory.Exists(ToExtended(path)); } catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
internal static bool Exists(string path) => FileExists(path) || DirectoryExists(path);
|
||||
|
||||
internal static FileAttributes? TryGetAttributes(string path)
|
||||
{
|
||||
try { return File.GetAttributes(ToExtended(path)); }
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
internal static bool IsDirectory(string path)
|
||||
=> (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.Directory) != 0;
|
||||
|
||||
internal static bool IsReparsePoint(string path)
|
||||
=> (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.ReparsePoint) != 0;
|
||||
|
||||
internal static long TryGetLength(string path)
|
||||
{
|
||||
try { return new FileInfo(ToExtended(path)).Length; }
|
||||
catch (Exception) { return 0; }
|
||||
}
|
||||
|
||||
/// <summary>清掉只读属性,否则覆盖/删除会抛 UnauthorizedAccessException。</summary>
|
||||
internal static void ClearReadOnly(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attrs = File.GetAttributes(ToExtended(path));
|
||||
if ((attrs & FileAttributes.ReadOnly) != 0)
|
||||
File.SetAttributes(ToExtended(path), attrs & ~FileAttributes.ReadOnly);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 不存在或无权访问:交给真正的操作去抛错,这里不吞掉信息。
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>确保父目录存在(撤销时目标父目录可能已被删掉)。</summary>
|
||||
internal static void EnsureParentDirectory(string path)
|
||||
{
|
||||
var parent = GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(ToExtended(parent));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成 "名字 (2).ext" 风格的不冲突路径;若本身已带 " (n)" 后缀,先剥离再递增,
|
||||
/// 避免出现 "a (2) (2).txt" 这种叠加命名。
|
||||
/// </summary>
|
||||
internal static string MakeUniquePath(string desiredPath)
|
||||
{
|
||||
if (!Exists(desiredPath)) return desiredPath;
|
||||
|
||||
var directory = GetDirectoryName(desiredPath);
|
||||
var name = GetFileName(desiredPath);
|
||||
var ext = Path.GetExtension(name);
|
||||
var stem = ext.Length > 0 ? name[..^ext.Length] : name;
|
||||
|
||||
var m = CopySuffixRegex().Match(stem);
|
||||
if (m.Success) stem = m.Groups[1].Value;
|
||||
|
||||
for (var i = 2; i < 100_000; i++)
|
||||
{
|
||||
var candidate = Combine(directory, $"{stem} ({i}){ext}");
|
||||
if (!Exists(candidate)) return candidate;
|
||||
}
|
||||
|
||||
throw new IOException($"无法为“{desiredPath}”生成不冲突的新名称。");
|
||||
}
|
||||
|
||||
/// <summary>校验文件名(重命名/新建文件夹用),错误信息为中文。</summary>
|
||||
internal static bool TryValidateFileName(string? name, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
error = "名称不能为空。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.Length > 255)
|
||||
{
|
||||
error = "名称过长(最多 255 个字符)。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
if (name.IndexOfAny(invalid) >= 0)
|
||||
{
|
||||
var bad = new string(name.Where(c => Array.IndexOf(invalid, c) >= 0).Distinct().ToArray());
|
||||
error = $"名称包含非法字符:{bad}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.EndsWith(' ') || name.EndsWith('.'))
|
||||
{
|
||||
error = "名称不能以空格或点结尾。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.TrimEnd(' ', '.').Length == 0)
|
||||
{
|
||||
error = "名称不能只由空格或点组成。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var stem = Path.GetFileNameWithoutExtension(name);
|
||||
if (IsReservedDeviceName(stem))
|
||||
{
|
||||
error = $"“{stem}”是 Windows 保留设备名,不能用作文件名。";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsReservedDeviceName(string stem)
|
||||
{
|
||||
if (stem.Length is 3 or 4)
|
||||
{
|
||||
if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase) ||
|
||||
stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) ||
|
||||
stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) ||
|
||||
stem.Equals("NUL", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (stem.Length == 4 && stem[3] is >= '1' and <= '9')
|
||||
{
|
||||
var head = stem[..3];
|
||||
if (head.Equals("COM", StringComparison.OrdinalIgnoreCase) ||
|
||||
head.Equals("LPT", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安全枚举某个目录的直接子项(返回普通路径)。
|
||||
/// 单个子目录无权限/枚举中途出错时返回已拿到的部分并回调警告,绝不抛出。
|
||||
/// </summary>
|
||||
internal static List<string> EnumerateChildrenSafe(string directory, Action<string>? onWarning = null)
|
||||
{
|
||||
var result = new List<string>();
|
||||
IEnumerator<string>? enumerator = null;
|
||||
try
|
||||
{
|
||||
enumerator = Directory.EnumerateFileSystemEntries(ToExtended(directory)).GetEnumerator();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
onWarning?.Invoke($"无法枚举目录“{directory}”:{ex.Message}");
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
string current;
|
||||
try
|
||||
{
|
||||
if (!enumerator.MoveNext()) break;
|
||||
current = enumerator.Current;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
onWarning?.Invoke($"枚举目录“{directory}”时中断:{ex.Message}");
|
||||
break;
|
||||
}
|
||||
|
||||
result.Add(StripExtended(current));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
enumerator.Dispose();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>把 \\?\ 前缀路径还原成普通路径(供 P/Invoke Shell API 使用)。</summary>
|
||||
internal static string ToShellPath(string path) => StripExtended(path);
|
||||
|
||||
/// <summary>判断 extended 前缀是否已存在(调试用)。</summary>
|
||||
internal static bool HasExtendedPrefix(string path)
|
||||
=> path.StartsWith(ExtendedPrefix, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>分配 UTF-16 双 null 结尾路径列表(SHFileOperationW 要求)。</summary>
|
||||
internal static IntPtr AllocDoubleNullList(IEnumerable<string> paths)
|
||||
{
|
||||
var joined = string.Join('\0', paths) + "\0\0";
|
||||
return Marshal.StringToHGlobalUni(joined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>回收站里一条 $I 索引记录解析出来的信息。</summary>
|
||||
internal sealed record RecycleBinItem(string IndexPath, string DataPath, string OriginalPath, long Size, DateTime DeletedUtc);
|
||||
|
||||
/// <summary>
|
||||
/// 回收站定位器:
|
||||
/// 1. 用 shell32!SHFileOperationW(FO_DELETE + FOF_ALLOWUNDO) 把一批路径送进回收站(一次调用完成一批);
|
||||
/// 2. 通过"操作前后 <卷>:\$Recycle.Bin\<SID>\ 目录里 $I* 文件的差集"定位本次新增的回收站条目,
|
||||
/// 解析 $I 结构拿到原始路径,并把 $I 前缀换成 $R 得到回收站内的真实数据路径,
|
||||
/// 从而支持 Ctrl+Z 一步还原。
|
||||
///
|
||||
/// $I 文件结构(Win10+ 为版本 2):
|
||||
/// offset 0 8B 版本号(Win10+ = 2)
|
||||
/// offset 8 8B 原始文件大小
|
||||
/// offset 16 8B 删除时间(FILETIME)
|
||||
/// offset 24 4B 文件名长度(仅版本 >= 2 存在)
|
||||
/// offset 24/28 UTF-16LE 的原始完整路径,以 \0 结尾
|
||||
/// 路径长度字段在不同 Windows 版本上语义有歧义(字符数 / 字节数两种实现都有),
|
||||
/// 因此这里直接读到缓冲区末尾并按第一个 \0 截断,比依赖该字段更稳。
|
||||
/// </summary>
|
||||
internal static partial class RecycleBinLocator
|
||||
{
|
||||
private const uint FO_DELETE = 0x0003;
|
||||
private const ushort FOF_SILENT = 0x0004;
|
||||
private const ushort FOF_NOCONFIRMATION = 0x0010;
|
||||
private const ushort FOF_ALLOWUNDO = 0x0040;
|
||||
private const ushort FOF_NOERRORUI = 0x0400;
|
||||
private const ushort FOF_WANTNUKEWARNING = 0x4000;
|
||||
|
||||
private const ushort DeleteFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI | FOF_WANTNUKEWARNING;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SHFILEOPSTRUCTW
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint wFunc;
|
||||
public IntPtr pFrom;
|
||||
public IntPtr pTo;
|
||||
public ushort fFlags;
|
||||
public int fAnyOperationsAborted;
|
||||
public IntPtr hNameMappings;
|
||||
public IntPtr lpszProgressTitle;
|
||||
}
|
||||
|
||||
// 用传统 DllImport:结构体全是 blittable 字段,不需要 LibraryImport 的 unsafe 代码生成,
|
||||
// 这样本层不引入 AllowUnsafeBlocks 依赖,任何项目链接这些源码都能直接编译。
|
||||
[DllImport("shell32.dll", EntryPoint = "SHFileOperationW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
|
||||
private static extern int SHFileOperation(ref SHFILEOPSTRUCTW lpFileOp);
|
||||
|
||||
// ------------------------------------------------------------ 删除到回收站
|
||||
|
||||
/// <summary>把一批路径送进回收站。必须一次调用完成一批(Shell 语义)。</summary>
|
||||
internal static (bool Success, bool Aborted, int Code) DeleteToRecycleBin(IReadOnlyList<string> paths)
|
||||
{
|
||||
if (paths.Count == 0) return (true, false, 0);
|
||||
|
||||
// 注意:Shell API 只接受普通路径,绝不能带 \\?\ 前缀。
|
||||
var from = PathHelper.AllocDoubleNullList(paths.Select(PathHelper.ToShellPath));
|
||||
var op = new SHFILEOPSTRUCTW
|
||||
{
|
||||
hwnd = IntPtr.Zero,
|
||||
wFunc = FO_DELETE,
|
||||
pFrom = from,
|
||||
pTo = IntPtr.Zero,
|
||||
fFlags = DeleteFlags,
|
||||
fAnyOperationsAborted = 0,
|
||||
hNameMappings = IntPtr.Zero,
|
||||
lpszProgressTitle = IntPtr.Zero
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var code = SHFileOperation(ref op);
|
||||
var aborted = op.fAnyOperationsAborted != 0;
|
||||
return (code == 0 && !aborted, aborted, code);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(from);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在专用 STA 线程上执行 SHFileOperationW。
|
||||
/// Shell 函数在内部会做 COM/OLE 相关工作,用 STA 线程调用最稳妥;
|
||||
/// 该线程是后台线程,不会阻塞 UI,也不会阻止进程退出。
|
||||
/// </summary>
|
||||
internal static Task<(bool Success, bool Aborted, int Code)> DeleteToRecycleBinAsync(IReadOnlyList<string> paths)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<(bool Success, bool Aborted, int Code)>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
tcs.TrySetResult(DeleteToRecycleBin(paths));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "FluidExplorer-RecycleBin"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
// 非 Windows 平台(本引擎实际只跑 Windows):直接以 MTA 启动。
|
||||
}
|
||||
|
||||
thread.Start();
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
internal static string DescribeResult(int code) => code switch
|
||||
{
|
||||
0 => "成功",
|
||||
2 => "找不到指定的文件。",
|
||||
3 => "找不到指定的路径。",
|
||||
5 => "拒绝访问。",
|
||||
0x20 => "共享冲突(文件正被其它进程使用)。",
|
||||
0x71 => "源与目标是同一个文件。",
|
||||
0x72 => "多个源文件对应单个目标(目录)。",
|
||||
0x73 => "源与目标处于不同目录。",
|
||||
0x74 => "不能对根目录执行该操作。",
|
||||
0x75 => "操作已被取消。",
|
||||
0x76 => "目标位于源的子树中。",
|
||||
0x78 => "访问源文件被拒绝。",
|
||||
0x79 => "路径层级过深。",
|
||||
0x7A => "目标过多。",
|
||||
0x7C => "存在无效文件名。",
|
||||
0x7D => "目标与源在同一目录树内。",
|
||||
0x7E => "目标为文件,但源为文件夹。",
|
||||
0x80 => "目标为文件夹,但源为文件。",
|
||||
0x81 => "文件名过长。",
|
||||
0x82 => "目标磁盘为 CD-ROM。",
|
||||
0x83 => "目标磁盘为 DVD。",
|
||||
0x84 => "目标磁盘为可刻录光盘。",
|
||||
0x85 => "文件过大。",
|
||||
0x86 => "源磁盘为 CD-ROM。",
|
||||
0x87 => "源磁盘为 DVD。",
|
||||
0x88 => "源磁盘为可刻录光盘。",
|
||||
0xB7 => "超过文件名/路径长度上限。",
|
||||
0x10000 => "目标上发生未指明的错误。",
|
||||
_ => $"SHFileOperation 返回错误码 0x{code:X}。"
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------ $I / $R 定位
|
||||
|
||||
/// <summary>取当前进程用户的 SID 字符串(回收站目录名)。</summary>
|
||||
internal static string? TryGetCurrentUserSid()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return identity.User?.Value;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>某个卷的回收站目录:<卷>:\$Recycle.Bin\<SID>\(不存在返回 null)。</summary>
|
||||
internal static string? GetRecycleBinDirectory(string volumeRoot)
|
||||
{
|
||||
var sid = TryGetCurrentUserSid();
|
||||
if (string.IsNullOrEmpty(sid)) return null;
|
||||
var dir = PathHelper.Combine(PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin"), sid);
|
||||
return PathHelper.DirectoryExists(dir) ? dir : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 快照:卷 → 该卷回收站里所有 $I 文件的完整路径集合。
|
||||
/// 主体扫描 <卷>:\$Recycle.Bin\<当前用户 SID>\,同时兜底扫描其它 SID 目录
|
||||
/// (进程可能以别的账户删除过文件)。
|
||||
/// </summary>
|
||||
internal static Dictionary<string, HashSet<string>> CaptureState(IReadOnlyList<string> paths)
|
||||
{
|
||||
var volumes = new List<string>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var root = PathHelper.GetVolumeRoot(path);
|
||||
if (root.Length < 2) continue;
|
||||
if (!volumes.Contains(root, StringComparer.OrdinalIgnoreCase)) volumes.Add(root);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var volume in volumes) result[volume] = EnumerateIndexFiles(volume);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static HashSet<string> EnumerateIndexFiles(string volumeRoot)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var binRoot = PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin");
|
||||
if (!PathHelper.DirectoryExists(binRoot)) return set;
|
||||
|
||||
var directories = new List<string>();
|
||||
|
||||
var ownSidDirectory = GetRecycleBinDirectory(volumeRoot);
|
||||
if (ownSidDirectory is not null) directories.Add(ownSidDirectory);
|
||||
|
||||
foreach (var sub in PathHelper.EnumerateChildrenSafe(binRoot))
|
||||
{
|
||||
if (!PathHelper.DirectoryExists(sub)) continue;
|
||||
if (!directories.Contains(sub, StringComparer.OrdinalIgnoreCase)) directories.Add(sub);
|
||||
}
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
foreach (var file in PathHelper.EnumerateChildrenSafe(directory))
|
||||
{
|
||||
var name = PathHelper.GetFileName(file);
|
||||
if (name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) set.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用"操作前后目录快照差集"找出本次新增的回收站条目,并用 $I 里的原始路径做二次确认,
|
||||
/// 产出 (回收站内 $R 数据路径, 原始路径) 列表,可直接填入 <see cref="UndoEntry.Deleted"/>。
|
||||
/// 定位失败的项也会产出记录(回收站路径为空串),撤销时按"无法还原"计入失败,不会崩溃。
|
||||
/// </summary>
|
||||
internal static (List<(string RecyclePath, string OriginalPath)> Items, List<string> Diagnostics) ResolveDeletedItems(
|
||||
IReadOnlyList<string> deletedPaths,
|
||||
Dictionary<string, HashSet<string>> before)
|
||||
{
|
||||
var items = new List<(string RecyclePath, string OriginalPath)>();
|
||||
var diagnostics = new List<string>();
|
||||
var remaining = new List<string>(deletedPaths);
|
||||
|
||||
foreach (var (volume, previous) in before)
|
||||
{
|
||||
var current = EnumerateIndexFiles(volume);
|
||||
var added = current.Where(f => !previous.Contains(f)).OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (added.Count == 0) continue;
|
||||
|
||||
foreach (var indexFile in added)
|
||||
{
|
||||
var parsed = TryParseIndexFile(indexFile);
|
||||
if (parsed is null)
|
||||
{
|
||||
diagnostics.Add($"$I 解析失败(长度/格式异常):{indexFile}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 匹配策略:先按原始完整路径精确匹配,再退化为"同名文件"匹配
|
||||
// (一次 SHFileOperation 调用内完成的条目,时间窗天然一致)。
|
||||
var match = remaining.FirstOrDefault(p => SamePath(p, parsed.OriginalPath))
|
||||
?? remaining.FirstOrDefault(p => string.Equals(
|
||||
PathHelper.GetFileName(p), PathHelper.GetFileName(parsed.OriginalPath), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
diagnostics.Add($"回收站新增条目未能匹配本次删除路径:{indexFile}(原始路径 {parsed.OriginalPath})");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!PathHelper.Exists(parsed.DataPath))
|
||||
diagnostics.Add($"找到 $I 记录但缺少对应的 $R 数据文件:{parsed.DataPath}");
|
||||
|
||||
items.Add((parsed.DataPath, match));
|
||||
remaining.Remove(match);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var path in remaining)
|
||||
{
|
||||
diagnostics.Add($"未能在回收站定位到“{path}”的 $I/$R 记录(可能被永久删除或回收站不可用),撤销时该项将按“无法还原”处理。");
|
||||
items.Add((string.Empty, path));
|
||||
}
|
||||
|
||||
return (items, diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>解析单个 $I 索引文件。</summary>
|
||||
internal static RecycleBinItem? TryParseIndexFile(string indexFilePath)
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = File.ReadAllBytes(PathHelper.ToExtended(indexFilePath));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bytes.Length < 28) return null;
|
||||
|
||||
var version = BitConverter.ToInt64(bytes, 0);
|
||||
var size = BitConverter.ToInt64(bytes, 8);
|
||||
var fileTime = BitConverter.ToInt64(bytes, 16);
|
||||
|
||||
DateTime deletedUtc;
|
||||
try
|
||||
{
|
||||
deletedUtc = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
deletedUtc = DateTime.MinValue;
|
||||
}
|
||||
|
||||
// 版本 1(Vista/7)无文件名长度字段;版本 2(Win10+)多 4 字节。
|
||||
var pathOffset = version >= 2 ? 28 : 24;
|
||||
if (bytes.Length <= pathOffset) return null;
|
||||
|
||||
var payload = bytes.AsSpan(pathOffset);
|
||||
if ((payload.Length & 1) == 1) payload = payload[..^1]; // UTF-16 按 2 字节对齐
|
||||
|
||||
var chars = MemoryMarshal.Cast<byte, char>(payload);
|
||||
var terminator = chars.IndexOf('\0');
|
||||
if (terminator >= 0) chars = chars[..terminator];
|
||||
if (chars.Length == 0) return null;
|
||||
|
||||
var originalPath = new string(chars);
|
||||
var name = PathHelper.GetFileName(indexFilePath);
|
||||
if (name.Length < 3 || !name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
// $R 对应文件:把 $I 换成 $R 前缀即为回收站内的实际数据路径(目录同样适用)。
|
||||
var dataPath = PathHelper.Combine(PathHelper.GetDirectoryName(indexFilePath), "$R" + name[2..]);
|
||||
|
||||
return new RecycleBinItem(indexFilePath, dataPath, originalPath, size, deletedUtc);
|
||||
}
|
||||
|
||||
/// <summary>由 $R 数据路径反推对应的 $I 索引路径。</summary>
|
||||
internal static string? GetIndexPathFromDataPath(string recycleDataPath)
|
||||
{
|
||||
var name = PathHelper.GetFileName(recycleDataPath);
|
||||
if (name.Length < 3 || !name.StartsWith("$R", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
return PathHelper.Combine(PathHelper.GetDirectoryName(recycleDataPath), "$I" + name[2..]);
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b)
|
||||
{
|
||||
var na = (PathHelper.TryGetFullPath(a) ?? a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var nb = (PathHelper.TryGetFullPath(b) ?? b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user