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 了):保留,不算失败。
}
}
}