Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)

This commit is contained in:
WpyQwq
2026-09-19 11:54:03 +08:00
commit 5780fde61a
60 changed files with 15035 additions and 0 deletions
+690
View File
@@ -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 了):保留,不算失败。
}
}
}
+922
View File
@@ -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);
}
+282
View File
@@ -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();
}
}
+330
View File
@@ -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,
/// 以保证超长路径(&gt;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);
}
}
+353
View File
@@ -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. 通过"操作前后 &lt;卷&gt;:\$Recycle.Bin\&lt;SID&gt;\ 目录里 $I* 文件的差集"定位本次新增的回收站条目,
/// 解析 $I 结构拿到原始路径,并把 $I 前缀换成 $R 得到回收站内的真实数据路径,
/// 从而支持 Ctrl+Z 一步还原。
///
/// $I 文件结构(Win10+ 为版本 2):
/// offset 0 8B 版本号(Win10+ = 2)
/// offset 8 8B 原始文件大小
/// offset 16 8B 删除时间(FILETIME)
/// offset 24 4B 文件名长度(仅版本 &gt;= 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>某个卷的回收站目录:&lt;卷&gt;:\$Recycle.Bin\&lt;SID&gt;\(不存在返回 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 文件的完整路径集合。
/// 主体扫描 &lt;卷&gt;:\$Recycle.Bin\&lt;当前用户 SID&gt;\,同时兜底扫描其它 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);
}
}