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
+74
View File
@@ -0,0 +1,74 @@
using FluidExplorer.Services.FileSystem;
using FluidExplorer.Services.Icons;
using FluidExplorer.Services.Operations;
using FluidExplorer.Services.Search;
using FluidExplorer.ViewModels;
using Microsoft.UI.Dispatching;
namespace FluidExplorer.Services;
/// <summary>组合根:所有服务在这里创建、装配,并交给视图模型。</summary>
public sealed class AppServices
{
public AppServices(DispatcherQueue ui)
{
Ui = ui;
Settings = AppSettings.Load();
FileSystem = new FastFileSystemService();
Search = new SearchService(FileSystem);
Icons = CreateIconService(ui);
Operations = CreateOperationService();
Main = new MainViewModel(FileSystem, Search, Operations, Icons, Settings, ui);
}
public DispatcherQueue Ui { get; }
public AppSettings Settings { get; }
public IFileSystemService FileSystem { get; }
public SearchService Search { get; }
public IIconService Icons { get; }
public IFileOperationService Operations { get; }
public MainViewModel Main { get; }
/// <summary>接入 Windows 外壳原版图标(imageres.dll / IShellItemImageFactory)。</summary>
private static IIconService CreateIconService(DispatcherQueue ui)
{
try
{
return new ShellIconService(ui);
}
catch
{
return new PlaceholderIconService();
}
}
/// <summary>接入文件操作队列引擎。</summary>
private static IFileOperationService CreateOperationService()
{
try
{
return new FileOperationService();
}
catch
{
return new PlaceholderOperationService();
}
}
/// <summary>接入 NTFS USN/MFT 索引(Everything 式极速搜索)。</summary>
public void WireSearchIndex()
{
Search.IndexFactory = volumeRoot =>
{
try
{
return new FluidExplorer.Services.Search.Usn.UsnVolumeIndex(volumeRoot);
}
catch
{
// 非 NTFS 卷等异常情况:该卷不建索引,搜索自动退化为实时扫描
return null;
}
};
}
}
+153
View File
@@ -0,0 +1,153 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using FluidExplorer.Models;
namespace FluidExplorer.Services;
/// <summary>外观模式:默认跟随系统(深浅色自动切换)。</summary>
public enum AppThemeMode
{
System,
Light,
Dark
}
public enum SearchScope
{
/// <summary>全盘(走 NTFS 索引,Everything 式,毫秒级)。</summary>
Global,
/// <summary>仅当前文件夹及其子目录。</summary>
CurrentFolder
}
public sealed class FolderViewState
{
public ViewMode ViewMode { get; set; } = ViewMode.Details;
public SortColumn SortColumn { get; set; } = SortColumn.Name;
public SortDirection SortDirection { get; set; } = SortDirection.Ascending;
public GroupBy GroupBy { get; set; } = GroupBy.None;
}
/// <summary>用户设置(%LOCALAPPDATA%\FluidExplorer\settings.json),失败时退回程序目录。</summary>
public sealed class AppSettings
{
public AppThemeMode ThemeMode { get; set; } = AppThemeMode.System;
// 视图
public bool ShowHiddenFiles { get; set; }
public bool ShowSystemFiles { get; set; }
public bool ShowFileExtensions { get; set; } = true;
public bool AlwaysShowCheckBoxes { get; set; }
public bool ShowStatusBar { get; set; } = true;
// 浏览
public bool OpenFoldersInNewTab { get; set; }
public bool DoubleClickToOpen { get; set; } = true;
public bool RestoreTabsOnStartup { get; set; } = true;
public string DefaultStartPath { get; set; } = "";
public List<string> PinnedFolders { get; set; } = [];
public List<string> RecentFolders { get; set; } = [];
public List<string> OpenTabs { get; set; } = [];
public bool DualPane { get; set; }
// 搜索
public SearchScope SearchScope { get; set; } = SearchScope.Global;
public List<string> IndexedVolumes { get; set; } = [];
public bool IndexOnStartup { get; set; } = true;
public bool SearchAsYouType { get; set; } = true;
// 文件操作
public bool DeleteToRecycleBin { get; set; } = true;
public ConflictPolicySetting DefaultConflictPolicy { get; set; } = ConflictPolicySetting.Ask;
public bool ConfirmPermanentDelete { get; set; } = true;
public bool KeepWindowOpenDuringOperations { get; set; } = true;
// 动效(克制:只保留系统原生的必要动画)
public bool AnimationsEnabled { get; set; } = true;
// 窗口
public double WindowLeft { get; set; } = double.NaN;
public double WindowTop { get; set; } = double.NaN;
public double WindowWidth { get; set; } = 1280;
public double WindowHeight { get; set; } = 800;
public bool WindowMaximized { get; set; }
/// <summary>按文件夹记住视图方式(对齐资源管理器的"按文件夹记住视图设置")。</summary>
public Dictionary<string, FolderViewState> FolderViews { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[JsonIgnore]
public string SettingsFilePath { get; private set; } = string.Empty;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static AppSettings Load()
{
foreach (var candidate in CandidatePaths())
{
try
{
if (!File.Exists(candidate)) continue;
var json = File.ReadAllText(candidate);
var loaded = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions);
if (loaded is null) continue;
loaded.SettingsFilePath = candidate;
return loaded;
}
catch
{
// 设置文件损坏时忽略,使用默认值
}
}
var settings = new AppSettings();
settings.SettingsFilePath = CandidatePaths().First();
return settings;
}
public void Save()
{
foreach (var target in CandidatePaths())
{
try
{
var dir = Path.GetDirectoryName(target);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
SettingsFilePath = target;
File.WriteAllText(target, JsonSerializer.Serialize(this, JsonOptions));
return;
}
catch
{
// 换下一个候选位置(例如沙箱/只读环境)
}
}
}
public FolderViewState GetFolderView(string path)
{
if (FolderViews.TryGetValue(path, out var state)) return state;
state = new FolderViewState();
FolderViews[path] = state;
return state;
}
private static IEnumerable<string> CandidatePaths()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrEmpty(localAppData))
yield return Path.Combine(localAppData, "FluidExplorer", "settings.json");
yield return Path.Combine(AppContext.BaseDirectory, "settings.json");
}
}
public enum ConflictPolicySetting
{
Ask,
Replace,
Skip,
KeepBoth
}
@@ -0,0 +1,223 @@
using System.Diagnostics;
using System.IO.Enumeration;
using FluidExplorer.Models;
namespace FluidExplorer.Services.FileSystem;
/// <summary>
/// 资源管理器最核心的性能路径:目录枚举。
/// 使用 .NET 的 FileSystemEnumerable(底层为 NtQueryDirectoryFile + 大缓冲批量返回),
/// 一次调用即可拿到名称/属性/大小/时间,比 DirectoryInfo 逐文件查询快一个数量级。
/// </summary>
public sealed class FastFileSystemService : IFileSystemService
{
private static readonly EnumerationOptions Options = new()
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = 0, // 隐藏/系统文件也枚举出来,由界面决定是否显示
ReturnSpecialDirectories = false,
MatchType = MatchType.Simple,
BufferSize = 0 // 0 = 使用平台默认的大缓冲区
};
public bool DirectoryExists(string path)
{
try { return Directory.Exists(path); }
catch { return false; }
}
public bool FileExists(string path)
{
try { return File.Exists(path); }
catch { return false; }
}
/// <summary>
/// 分批异步枚举:首批(batchSize 条)会尽快回调,让界面立刻有内容;
/// 整个枚举可取消,不会阻塞调用线程。
/// </summary>
public async Task EnumerateAsync(
string path,
FolderListing listing,
Func<IReadOnlyList<FileEntry>, Task> onBatch,
int batchSize = 256,
CancellationToken cancellationToken = default)
{
listing.Path = path;
listing.IsLoading = true;
listing.IsComplete = false;
listing.Error = null;
try
{
await Task.Run(async () =>
{
var buffer = new List<FileEntry>(batchSize);
var sw = Stopwatch.StartNew();
foreach (var entry in EnumerateCore(path, cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
buffer.Add(entry);
if (buffer.Count >= batchSize)
{
await onBatch(buffer).ConfigureAwait(false);
buffer = new List<FileEntry>(batchSize);
}
}
if (buffer.Count > 0) await onBatch(buffer).ConfigureAwait(false);
_ = sw.Elapsed;
}, cancellationToken).ConfigureAwait(false);
listing.IsComplete = true;
}
catch (OperationCanceledException)
{
listing.IsComplete = false;
}
catch (UnauthorizedAccessException ex)
{
listing.Error = $"没有访问权限:{ex.Message}";
}
catch (DirectoryNotFoundException)
{
listing.Error = "文件夹不存在或已被移动。";
}
catch (IOException ex)
{
// 网络路径断开、设备未就绪等:给出可读提示而不是抛出
listing.Error = $"无法读取此位置:{ex.Message}";
}
finally
{
listing.IsLoading = false;
}
}
public IReadOnlyList<FileEntry> Enumerate(string path, bool includeHidden = true)
{
var list = new List<FileEntry>(256);
foreach (var e in EnumerateCore(path, CancellationToken.None))
{
if (!includeHidden && e.IsHidden) continue;
list.Add(e);
}
return list;
}
/// <summary>核心枚举:单次系统调用序列,无逐文件 stat。</summary>
private static IEnumerable<FileEntry> EnumerateCore(string path, CancellationToken cancellationToken)
{
var normalized = PathHelper.NormalizeForApi(path);
var enumerable = new FileSystemEnumerable<FileEntry>(
normalized,
static (ref FileSystemEntry entry) => new FileEntry
{
Name = entry.FileName.ToString(),
FullPath = entry.ToFullPath(),
IsDirectory = entry.IsDirectory,
Size = entry.IsDirectory ? 0 : entry.Length,
ModifiedUtc = entry.LastWriteTimeUtc.UtcDateTime,
CreatedUtc = entry.CreationTimeUtc.UtcDateTime,
AccessedUtc = entry.LastAccessTimeUtc.UtcDateTime,
Attributes = entry.Attributes
},
Options);
foreach (var item in enumerable)
{
cancellationToken.ThrowIfCancellationRequested();
yield return item;
}
}
/// <summary>递归求文件夹大小:并行遍历 + 可取消,用于属性对话框与状态栏提示。</summary>
public Task<long> GetDirectorySizeAsync(string path, CancellationToken cancellationToken)
=> Task.Run(() =>
{
long total = 0;
var stack = new Stack<string>();
stack.Push(path);
while (stack.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var dir = stack.Pop();
try
{
foreach (var file in Directory.EnumerateFiles(dir))
{
try { total += new FileInfo(file).Length; } catch { /* 跳过无法访问的项 */ }
}
foreach (var sub in Directory.EnumerateDirectories(dir))
{
var info = new DirectoryInfo(sub);
if ((info.Attributes & FileAttributes.ReparsePoint) != 0) continue; // 不跟随链接,防止环
stack.Push(sub);
}
}
catch { /* 跳过无权限目录 */ }
}
return total;
}, cancellationToken);
}
/// <summary>路径规范化统一入口(长路径、去掉尾部分隔符、避免重复分隔符)。</summary>
public static class PathHelper
{
public const string ExtendedPrefix = @"\\?\";
public const string ExtendedUncPrefix = @"\\?\UNC\";
/// <summary>用于 Win32/文件系统 API 的路径(超长路径自动加 \\?\ 前缀)。</summary>
public static string NormalizeForApi(string path)
{
if (string.IsNullOrEmpty(path)) return path;
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal)) return path;
path = path.Replace('/', '\\');
// 去掉重复分隔符(保留 UNC 开头的两个)
while (path.Contains(@"\\") && !path.StartsWith(@"\\", StringComparison.Ordinal)) path = path.Replace(@"\\", @"\");
if (path.Length >= 248)
{
return path.StartsWith(@"\\", StringComparison.Ordinal)
? ExtendedUncPrefix + path[2..]
: ExtendedPrefix + path;
}
return path;
}
/// <summary>去掉 \\?\ 前缀,用于显示。</summary>
public static string StripExtendedPrefix(string path)
{
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal)) return @"\\" + path[ExtendedUncPrefix.Length..];
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal)) return path[ExtendedPrefix.Length..];
return path;
}
/// <summary>规范化显示路径:统一分隔符、去掉末尾分隔符(根目录除外)。</summary>
public static string NormalizeDisplay(string path)
{
if (string.IsNullOrEmpty(path)) return path;
var p = StripExtendedPrefix(path).Replace('/', '\\');
if (p.Length > 3 && p.EndsWith('\\')) p = p.TrimEnd('\\');
return p;
}
public static string GetParent(string path)
{
var p = NormalizeDisplay(path);
if (p.Length <= 3) return p; // "C:\" 的父级还是自己
var idx = p.LastIndexOf('\\');
if (idx < 0) return p;
var parent = p[..idx];
if (parent.Length == 2 && parent[1] == ':') parent += "\\";
return parent.Length == 0 ? p : parent;
}
public static string GetName(string path)
{
var p = NormalizeDisplay(path);
if (p.Length <= 3) return p;
var idx = p.LastIndexOf('\\');
return idx < 0 ? p : p[(idx + 1)..];
}
}
+30
View File
@@ -0,0 +1,30 @@
using FluidExplorer.Models;
namespace FluidExplorer.Services.FileSystem;
/// <summary>
/// 快速目录枚举:内部使用 .NET 的 FileSystemEnumerable(NtQueryDirectoryFile 批量缓冲)
/// 一次取回名称/属性/大小/时间,避免逐文件 Win32 调用。
/// </summary>
public interface IFileSystemService
{
/// <summary>
/// 异步枚举目录,分批回调(首批尽可能快,保证 UI 立刻有内容),
/// 整体可取消;无权限/网络超时不抛异常,通过 listing.Error 汇报。
/// </summary>
Task EnumerateAsync(
string path,
FolderListing listing,
Func<IReadOnlyList<FileEntry>, Task> onBatch,
int batchSize = 256,
CancellationToken cancellationToken = default);
/// <summary>同步枚举(供后台索引/搜索回退用),返回全部条目。</summary>
IReadOnlyList<FileEntry> Enumerate(string path, bool includeHidden = true);
bool DirectoryExists(string path);
bool FileExists(string path);
/// <summary>计算文件夹大小(后台、可取消)。</summary>
Task<long> GetDirectorySizeAsync(string path, CancellationToken cancellationToken);
}
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
namespace FluidExplorer.Services.Icons;
/// <summary>
/// 图标/缩略图服务:全部来自 Windows 外壳(imageres.dll 等系统图标库、IShellItemImageFactory),
/// 也就是资源管理器本身显示的那批原版图标,不做自制图标。
/// </summary>
public interface IIconService
{
/// <summary>系统小图标(16/32/48px 的多尺寸 HICON),用于列表与侧边栏。目录、驱动器、特殊文件夹同样走外壳。</summary>
Task<ImageSource?> GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken);
/// <summary>大缩略图(SIIGBF_THUMBNAILONLY + 图标回退),用于网格/磁贴视图。失败返回 null。</summary>
Task<ImageSource?> GetThumbnailAsync(string path, int size, CancellationToken cancellationToken);
/// <summary>按扩展名取通用图标(同类型文件共用一个位图,命中率极高)。</summary>
Task<ImageSource?> GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken);
/// <summary>取某个已知外壳文件夹(如 "shell:RecycleBinFolder"、"::{20D04FE0-3AEA-1069-A2D8-08002B30309D}")的图标。</summary>
ImageSource? GetSpecialFolderIcon(string parsingName, int size);
void ClearCache();
}
/// <summary>图标服务需要在 UI 线程创建 ImageSource,构造时注入 DispatcherQueue。</summary>
public interface IIconServiceHost
{
DispatcherQueue DispatcherQueue { get; }
}
+894
View File
@@ -0,0 +1,894 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
using BgraBuffer = FluidExplorer.Services.Icons.ShellNative.BgraBuffer;
namespace FluidExplorer.Services.Icons;
/// <summary>
/// 系统原版图标 / 缩略图服务。
///
/// 设计要点:
/// 1) 取图全部在外壳与 GDI 侧完成(可后台线程),只有 <see cref="SoftwareBitmapSource"/> 的创建被派回 UI 线程;
/// SoftwareBitmapSource 是 UI 线程亲和对象,后台线程碰它会直接崩。
/// 2) 结果带 LRU 缓存 + 同键请求合并(Lazy&lt;Task&gt;),列表快速滚动时不会重复解码同一张图。
/// 3) 任何未预期异常都会被吞成 null 并写 Debug 输出,服务层绝不把异常抛进 UI 线程。
/// </summary>
public sealed class ShellIconService : IIconService
{
private const int IconCacheCapacity = 512;
private const int ThumbnailCacheCapacity = 256;
private const int MaxConcurrentNativeWork = 4;
/// <summary>缩略图 E_PENDING 重试参数。</summary>
private const int ThumbnailRetryCount = 3;
/// <summary>缩略图 E_PENDING 重试间隔(毫秒)。</summary>
private const int ThumbnailRetryDelayMs = 200;
/// <summary>UI 调度器;极端情况下可能为 null(此时退化为在调用线程创建 ImageSource)。</summary>
private readonly DispatcherQueue? _uiDispatcher;
private readonly Func<double> _scaleProvider;
/// <summary>
/// 图标缓存:值类型是 <see cref="Task{TResult}"/>。
/// 缓存"共享的 Task"而不是最终 ImageSource,才能在解码未完成时就完成同键请求合并;
/// 这也是 Lazy&lt;Task&gt; 模式的落点(LruCache 内层用 Lazy 保证工厂只跑一次)。
/// </summary>
private readonly LruCache<Task<ImageSource?>> _iconCache = new(IconCacheCapacity);
/// <summary>缩略图缓存,容量更小(缩略图位图大得多,全部是 256px 级别的位图)。</summary>
private readonly LruCache<Task<ImageSource?>> _thumbnailCache = new(ThumbnailCacheCapacity);
/// <summary>把纯 GDI 取图/解码节流在 4 路,避免整屏滚动时把 CPU 打满。</summary>
private readonly SemaphoreSlim _nativeThrottle = new(MaxConcurrentNativeWork, MaxConcurrentNativeWork);
/// <summary>默认的 DPI 缩放(rasterizationScale 为 null 时按 1.0 处理)。</summary>
private static readonly Func<double> DefaultScale = static () => 1.0;
/// <summary>
/// 构造。签名固定为 (DispatcherQueue, Func&lt;double&gt;?),见 Services/AppServices.cs 的调用。
/// </summary>
/// <remarks>
/// 构造函数**不抛异常**:uiDispatcher 为 null 时回退到当前线程的 DispatcherQueue,
/// 再拿不到就退化为"直接在调用线程创建 ImageSource"。
/// 宁可降级也不要让 App 启动阶段直接崩掉。
/// </remarks>
public ShellIconService(DispatcherQueue uiDispatcher, Func<double>? rasterizationScale = null)
{
_uiDispatcher = uiDispatcher ?? TryGetCurrentDispatcher();
_scaleProvider = rasterizationScale ?? DefaultScale;
}
/// <summary>尽力拿到当前线程的 DispatcherQueue;拿不到返回 null(构造期不抛异常)。</summary>
private static DispatcherQueue? TryGetCurrentDispatcher()
{
try
{
return DispatcherQueue.GetForCurrentThread();
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] GetForCurrentThread 失败: {ex.Message}");
return null;
}
}
/// <summary>当前构造时注入的 UI 调度器(ViewModel 可用它确认自己在哪个线程上调用)。</summary>
public DispatcherQueue? UiDispatcher => _uiDispatcher;
// ─────────────────────────────────────────────────────────────
// IIconService
// ─────────────────────────────────────────────────────────────
/// <inheritdoc />
public Task<ImageSource?> GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken)
{
try
{
if (string.IsNullOrWhiteSpace(path)) return Task.FromResult<ImageSource?>(null);
int logical = NormalizeSize(size);
// 缓存键规则(关键,不要随意改动):
// - 文件 / 目录 / 驱动器一律用完整路径。因为 .exe 会带自身嵌入图标,
// .lnk 指向不同目标,文件夹可能带 OneDrive / 共享 / 快捷方式角标,
// 只有按路径缓存才不会串图。
// - 扩展名走 ".ext" 键(见 GetExtensionIconAsync),SHGFI_USEFILEATTRIBUTES 不碰磁盘。
string key = BuildIconKey(path, isDirectory, logical);
return GetOrAddAsync(_iconCache, key, ct => LoadIconCoreAsync(path, isDirectory, logical, ct), cancellationToken);
}
catch (Exception ex)
{
// 公开方法绝不抛异常给调用方(列表滚动会崩主程序)
Debug.WriteLine($"[ShellIconService] GetIconAsync 失败 {path}: {ex}");
return Task.FromResult<ImageSource?>(null);
}
}
/// <inheritdoc />
public Task<ImageSource?> GetThumbnailAsync(string path, int size, CancellationToken cancellationToken)
{
try
{
if (string.IsNullOrWhiteSpace(path)) return Task.FromResult<ImageSource?>(null);
int logical = NormalizeSize(size);
string key = "thumb|" + logical.ToString() + "|" + path;
return GetOrAddAsync(_thumbnailCache, key, ct => LoadThumbnailCoreAsync(path, logical, ct), cancellationToken);
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] GetThumbnailAsync 失败 {path}: {ex}");
return Task.FromResult<ImageSource?>(null);
}
}
/// <inheritdoc />
public Task<ImageSource?> GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken)
{
try
{
string ext = NormalizeExtension(extension);
if (ext.Length == 0) return Task.FromResult<ImageSource?>(null);
int logical = NormalizeSize(size);
// ".ext" 键:同类型文件共用一个位图,命中率极高,且不需要访问磁盘
string key = "ext|" + logical.ToString() + "|." + ext;
return GetOrAddAsync(_iconCache, key, ct => LoadExtensionIconCoreAsync(ext, logical, ct), cancellationToken);
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] GetExtensionIconAsync 失败 {extension}: {ex}");
return Task.FromResult<ImageSource?>(null);
}
}
/// <inheritdoc />
/// <remarks>
/// 注意:本方法是同步签名(见 IIconService)。它只应在 UI 线程调用,内部会同步等待取图完成,
/// 因此仅适合启动阶段取少量侧边栏图标,不要在滚动/渲染路径里按行调用。
/// </remarks>
public ImageSource? GetSpecialFolderIcon(string parsingName, int size)
{
try
{
if (string.IsNullOrWhiteSpace(parsingName)) return null;
// 该重载不会死锁:内部从不依赖调用方线程继续泵消息(软件位图那一段走 TryEnqueue)
return GetIconAsync(parsingName, isDirectory: true, size, CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(5))
.GetAwaiter()
.GetResult();
}
catch (TimeoutException ex)
{
Debug.WriteLine($"[ShellIconService] 特殊文件夹图标超时 {parsingName}: {ex.Message}");
return null;
}
catch (Exception ex)
{
// 含 OperationCanceledException:公开方法一律不外抛
Debug.WriteLine($"[ShellIconService] 特殊文件夹图标失败 {parsingName}: {ex}");
return null;
}
}
/// <inheritdoc />
public void ClearCache()
{
try
{
_iconCache.Clear();
_thumbnailCache.Clear();
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] ClearCache 失败: {ex}");
}
}
// ─────────────────────────────────────────────────────────────
// 取图主流程(全部在后台线程执行)
// ─────────────────────────────────────────────────────────────
/// <summary>文件 / 目录 / 驱动器 / 已知外壳对象的图标。</summary>
private Task<ImageSource?> LoadIconCoreAsync(string path, bool isDirectory, int logicalSize, CancellationToken ct)
{
return RunSafeAsync(async () =>
{
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
try
{
BgraBuffer? pixels = null;
if (IsSpecialParsingName(path))
{
// shell:RecycleBinFolder、::{20D04FE0-...}(此电脑)之类:
// 必须先解析成 PIDL,再交给 SHGetFileInfoW 的 PIDL 重载
pixels = ShellParsingName.IsShellPrefix(path)
? LoadIconFromShellParsingName(path, logicalSize)
: LoadIconFromParsingName(path, logicalSize);
}
else if (!isDirectory && logicalSize >= 48 && File.Exists(path))
{
// 大尺寸文件图标优先走 IShellItemImageFactory + SIIGBF_ICONONLY:
// 它返回的是外壳为该文件类型选定的高清图标(含 .exe/.lnk 的个性化图标),
// 比把 32x32 拉伸到 48 清晰得多。
// File.Exists 先挡一道:SHCreateItemFromParsingName 对不存在的路径会抛/失败,避免无谓开销。
pixels = LoadIconViaShellItemImageFactory(path, logicalSize);
}
// 回退 / 小尺寸路径:SHGetFileInfoW。
// 小尺寸用这条更贴近资源管理器的列表视图(16/32 就是系统图像列表原生尺寸,不经缩放)。
pixels ??= LoadIconViaShellFileInfo(path, isDirectory, logicalSize);
ct.ThrowIfCancellationRequested();
if (pixels is null) return null;
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
}
finally
{
_nativeThrottle.Release();
}
});
}
/// <summary>扩展名通用图标:SHGFI_USEFILEATTRIBUTES + FILE_ATTRIBUTE_NORMAL,不访问磁盘。</summary>
private Task<ImageSource?> LoadExtensionIconCoreAsync(string extension, int logicalSize, CancellationToken ct)
{
return RunSafeAsync(async () =>
{
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
try
{
// 路径传 "x.ext" 纯粹是为了让外壳从扩展名推断类型;
// SHGFI_USEFILEATTRIBUTES 保证它不会去访问磁盘(这也是它比按路径取快一个数量级的原因)。
string fakePath = "x." + extension;
BgraBuffer? pixels = LoadIconViaShellFileInfoCore(
fakePath,
ShellNative.FILE_ATTRIBUTE_NORMAL,
ShellNative.SHGFI_USEFILEATTRIBUTES,
logicalSize);
ct.ThrowIfCancellationRequested();
if (pixels is null) return null;
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
}
finally
{
_nativeThrottle.Release();
}
});
}
/// <summary>
/// 缩略图:SHCreateItemFromParsingName + IShellItemImageFactory.GetImage。
/// 失败一律返回 null(不抛异常);E_PENDING 表示外壳正在后台解码,等 200ms 重试,最多 3 次。
/// </summary>
private Task<ImageSource?> LoadThumbnailCoreAsync(string path, int logicalSize, CancellationToken ct)
{
return RunSafeAsync(async () =>
{
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
try
{
int pixelSize = ToPixelSize(logicalSize);
Guid iid = typeof(ShellNative.IShellItemImageFactory).GUID;
ShellNative.IShellItemImageFactory? factory = null;
try
{
int hr = ShellNative.SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out factory);
if (hr < 0 || factory is null)
{
Debug.WriteLine($"[ShellIconService] SHCreateItemFromParsingName 失败 0x{hr:X8}: {path}");
return null;
}
// THUMBNAILONLY:没有真实缩略图就直接失败(交给上层显示图标);
// BIGGERSIZEOK:允许外壳返回更大的缓存图,由 XAML 侧缩放,反而更清晰。
const int flags = ShellNative.SIIGBF_THUMBNAILONLY | ShellNative.SIIGBF_BIGGERSIZEOK;
BgraBuffer? pixels = null;
for (int attempt = 0; attempt <= ThumbnailRetryCount; attempt++)
{
ct.ThrowIfCancellationRequested();
var size = new ShellNative.SIZE(pixelSize, pixelSize);
int hrImage = factory.GetImage(size, flags, out IntPtr hBitmap);
if (hrImage == ShellNative.E_PENDING)
{
// 外壳正在生成缩略图:等一下再来
await Task.Delay(ThumbnailRetryDelayMs, ct).ConfigureAwait(false);
continue;
}
if (hrImage < 0 || hBitmap == IntPtr.Zero)
{
// 没有缩略图(例如 .txt / 未知类型)——这是正常情况,不记错误
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
return null;
}
try
{
pixels = ShellNative.BitmapToBgra(hBitmap, pixelSize, pixelSize);
}
finally
{
// IShellItemImageFactory 返回的 HBITMAP 归调用方所有,必须释放
ShellNative.DeleteObject(hBitmap);
}
break;
}
ct.ThrowIfCancellationRequested();
if (pixels is null) return null;
// 空位图保护:尺寸为 0 或无像素时不产出 ImageSource
if (pixels.Width <= 0 || pixels.Height <= 0 || pixels.Pixels.Length == 0) return null;
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
}
finally
{
if (factory is not null)
{
try
{
Marshal.FinalReleaseComObject(factory);
}
catch (ArgumentException)
{
// 已被释放:忽略
}
}
}
}
finally
{
_nativeThrottle.Release();
}
});
}
// ─────────────────────────────────────────────────────────────
// 各条取图路径的 Win32 细节
// ─────────────────────────────────────────────────────────────
/// <summary>SHGetFileInfoW 路径:目录走真实路径(保角标/个性化图标),文件走属性推断。</summary>
private BgraBuffer? LoadIconViaShellFileInfo(string path, bool isDirectory, int logicalSize)
{
if (isDirectory)
{
// 目录必须用真实路径:这样 OneDrive / 共享 / 快捷方式角标才会被带上
return LoadIconViaShellFileInfoCore(path, ShellNative.FILE_ATTRIBUTE_DIRECTORY, 0, logicalSize);
}
// 文件用 FILE_ATTRIBUTE_NORMAL 推断:不访问磁盘,速度极快。
// 注意路径仍然参与取值,所以 .exe 的嵌入图标、.lnk 的目标图标依然正确。
return LoadIconViaShellFileInfoCore(path, ShellNative.FILE_ATTRIBUTE_NORMAL, ShellNative.SHGFI_USEFILEATTRIBUTES, logicalSize);
}
/// <summary>
/// SHGetFileInfoW 核心:先拿系统图像列表索引 → 按 size 选 SHIL 取对应尺寸 HICON;
/// 拿不到索引就退回 SHGetFileInfoW 直接给的 HICON(32x32)。
/// </summary>
private BgraBuffer? LoadIconViaShellFileInfoCore(string path, uint attributes, uint extraFlags, int logicalSize)
{
int pixelSize = ToPixelSize(logicalSize);
int imageListKind = SelectImageList(logicalSize);
IntPtr hIcon = IntPtr.Zero;
try
{
int index = ShellNative.GetSystemIconIndexByPath(path, attributes, extraFlags);
if (index >= 0)
{
hIcon = ShellNative.GetHIconFromSystemImageList(imageListKind, index);
}
if (hIcon == IntPtr.Zero)
{
// 回退:直接取 HICON(32x32 或 16x16),大尺寸就走高质量缩放
uint iconFlags = logicalSize <= 16 ? ShellNative.SHGFI_SMALLICON : ShellNative.SHGFI_LARGEICON;
hIcon = ShellNative.GetHIconByPath(path, attributes, extraFlags | iconFlags);
}
if (hIcon == IntPtr.Zero) return null;
return ShellNative.IconToBgra(hIcon, pixelSize, pixelSize);
}
finally
{
// 每一次取到的 HICON 都必须销毁,否则 GDI 句柄会持续增长
if (hIcon != IntPtr.Zero) ShellNative.DestroyIcon(hIcon);
}
}
/// <summary>shell: / ::{CLSID} 解析名的图标:SHParseDisplayName → PIDL → 系统图像列表。</summary>
private BgraBuffer? LoadIconFromShellParsingName(string parsingName, int logicalSize)
{
IntPtr pidl = ShellNative.ParseDisplayNameToPidl(parsingName);
if (pidl == IntPtr.Zero) return null;
try
{
int pixelSize = ToPixelSize(logicalSize);
int imageListKind = SelectImageList(logicalSize);
IntPtr hIcon = IntPtr.Zero;
try
{
int index = ShellNative.GetSystemIconIndexByPidl(pidl, 0);
if (index >= 0)
{
hIcon = ShellNative.GetHIconFromSystemImageList(imageListKind, index);
}
if (hIcon == IntPtr.Zero)
{
uint iconFlags = logicalSize <= 16 ? ShellNative.SHGFI_SMALLICON : ShellNative.SHGFI_LARGEICON;
hIcon = ShellNative.GetHIconByPidl(pidl, iconFlags);
}
if (hIcon == IntPtr.Zero) return null;
return ShellNative.IconToBgra(hIcon, pixelSize, pixelSize);
}
finally
{
if (hIcon != IntPtr.Zero) ShellNative.DestroyIcon(hIcon);
}
}
finally
{
// PIDL 由 SHParseDisplayName 用 CoTaskMemAlloc 分配,必须 CoTaskMemFree
ShellNative.CoTaskMemFree(pidl);
}
}
/// <summary>带 "::{CLSID}" 前缀但不带 shell: 前缀的解析名。</summary>
private BgraBuffer? LoadIconFromParsingName(string parsingName, int logicalSize)
=> LoadIconFromShellParsingName(parsingName, logicalSize);
/// <summary>IShellItemImageFactory + SIIGBF_ICONONLY:大尺寸文件/扩展名的高清原版图标。</summary>
private BgraBuffer? LoadIconViaShellItemImageFactory(string path, int logicalSize)
{
int pixelSize = ToPixelSize(logicalSize);
Guid iid = typeof(ShellNative.IShellItemImageFactory).GUID;
ShellNative.IShellItemImageFactory? factory = null;
IntPtr hBitmap = IntPtr.Zero;
try
{
int hr = ShellNative.SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out factory);
if (hr < 0 || factory is null) return null;
// ICONONLY:只要图标不要缩略图;BIGGERSIZEOK:允许外壳给更大的原版图标
const int flags = ShellNative.SIIGBF_ICONONLY | ShellNative.SIIGBF_BIGGERSIZEOK;
hr = factory.GetImage(new ShellNative.SIZE(pixelSize, pixelSize), flags, out hBitmap);
if (hr < 0 || hBitmap == IntPtr.Zero)
{
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
return null;
}
return ShellNative.BitmapToBgra(hBitmap, pixelSize, pixelSize);
}
catch (COMException ex)
{
Debug.WriteLine($"[ShellIconService] IShellItemImageFactory(icon) 0x{ex.HResult:X8}: {path}");
return null;
}
finally
{
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
if (factory is not null)
{
try
{
Marshal.FinalReleaseComObject(factory);
}
catch (ArgumentException)
{
// 已被释放:忽略
}
}
}
}
// ─────────────────────────────────────────────────────────────
// 像素 → WinUI ImageSource(唯一需要 UI 线程的一段)
// ─────────────────────────────────────────────────────────────
/// <summary>
/// BGRA 像素 → <see cref="SoftwareBitmapSource"/>。
/// SoftwareBitmap 可在任意线程构造,但 SoftwareBitmapSource 必须在 UI 线程创建并 SetBitmapAsync,
/// 因此这里统一通过 <see cref="_uiDispatcher"/> 派回 UI 线程。
/// </summary>
private Task<ImageSource?> CreateImageSourceAsync(BgraBuffer buffer)
{
// SoftwareBitmap 是自由线程的,先在当前(后台)线程建好。
// 用 DataWriter 把 BGRA 字节装进 WinRT IBuffer(比 byte[].AsBuffer() 更省一次拷贝,
// 而且后者依赖 System.Runtime.InteropServices.WindowsRuntime 扩展,在部分 TFM 下不可用)。
var writer = new DataWriter();
writer.WriteBytes(buffer.Pixels);
IBuffer winrtBuffer = writer.DetachBuffer();
var bitmap = SoftwareBitmap.CreateCopyFromBuffer(
winrtBuffer,
BitmapPixelFormat.Bgra8,
buffer.Width,
buffer.Height,
// 图标/缩略图带 alpha,必须用预乘格式,否则半透明边缘会出现黑边
BitmapAlphaMode.Premultiplied);
return RunOnUiThreadAsync(async () =>
{
var source = new SoftwareBitmapSource();
try
{
await source.SetBitmapAsync(bitmap);
}
finally
{
// SetBitmapAsync 会拷贝像素,之后即可释放 SoftwareBitmap
bitmap.Dispose();
}
return (ImageSource)source;
});
}
/// <summary>
/// 把一段"必须跑在 UI 线程"的工作派回去执行,用 TaskCompletionSource 桥接。
/// 如果调用方已经在 UI 线程上,则直接同步执行,省掉一次调度往返。
/// </summary>
private Task<T?> RunOnUiThreadAsync<T>(Func<Task<T?>> work) where T : class
{
var dispatcher = _uiDispatcher;
if (dispatcher is null)
{
// 没有调度器(构造时未注入且当前线程也没有):退化为直接在调用线程创建,
// 总比直接失败好 —— 调用方若本来就在 UI 线程,这里完全正确。
return work();
}
if (dispatcher.HasThreadAccess)
{
// 已在 UI 线程:直接执行(SoftwareBitmapSource 亲和 UI 线程,此处满足条件)
return work();
}
var tcs = new TaskCompletionSource<T?>(TaskCreationOptions.RunContinuationsAsynchronously);
bool enqueued;
try
{
enqueued = dispatcher.TryEnqueue(async void () =>
{
try
{
tcs.TrySetResult(await work().ConfigureAwait(true));
}
catch (Exception ex)
{
// 派回 UI 线程的工作失败:以异常结束 Task,由上层 RunSafeAsync 统一吞掉
tcs.TrySetException(ex);
}
});
}
catch (Exception ex) when (ex is COMException or InvalidOperationException)
{
// DispatcherQueue 正在关闭(应用退出中)
Debug.WriteLine($"[ShellIconService] TryEnqueue 失败: {ex.Message}");
return Task.FromResult<T?>(null);
}
if (!enqueued)
{
Debug.WriteLine("[ShellIconService] TryEnqueue 返回 false(消息循环已停止)");
return Task.FromResult<T?>(null);
}
return tcs.Task;
}
// ─────────────────────────────────────────────────────────────
// 缓存 / 并发 / 异常兜底
// ─────────────────────────────────────────────────────────────
/// <summary>
/// 同键请求合并 + LRU 缓存。
/// Lazy&lt;Task&gt; 保证同一个键只会解码一次,其余调用方 await 同一个 Task(快速滚动不会重复解码)。
/// </summary>
private static Task<ImageSource?> GetOrAddAsync(
LruCache<Task<ImageSource?>> cache,
string key,
Func<CancellationToken, Task<ImageSource?>> factory,
CancellationToken ct)
{
// 缓存命中时必须尊重取消:已取消就直接返回 null
if (cache.TryGet(key, out var cachedTask))
{
return ct.IsCancellationRequested ? Task.FromResult<ImageSource?>(null) : cachedTask;
}
// 注意 T 是 Task<ImageSource?>:Lazy 的工厂返回的就是这个共享 Task
var lazy = cache.GetOrCreate(
key,
_ => new Lazy<Task<ImageSource?>>(
() => factory(CancellationToken.None),
LazyThreadSafetyMode.ExecutionAndPublication));
return AwaitLazyAsync(lazy, ct);
}
/// <summary>等待共享的 Lazy&lt;Task&gt;,但让"本次调用"的取消能立刻返回 null(不打断别人共享的那次解码)。</summary>
private static async Task<ImageSource?> AwaitLazyAsync(Lazy<Task<ImageSource?>> lazy, CancellationToken ct)
{
Task<ImageSource?> task;
try
{
task = lazy.Value;
}
catch (Exception ex)
{
// Lazy 的工厂本身抛异常(极少见,主要来自 factory 构造阶段)
Debug.WriteLine($"[ShellIconService] 缓存工厂异常: {ex}");
return null;
}
if (task.IsCompleted)
{
if (ct.IsCancellationRequested) return null;
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
}
if (!ct.CanBeCanceled)
{
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
}
using var cts = new CancellationTokenSource();
using (ct.Register(static state => ((CancellationTokenSource)state!).Cancel(), cts))
{
var cancelTask = Task.Delay(Timeout.Infinite, cts.Token);
var finished = await Task.WhenAny(task, cancelTask).ConfigureAwait(false);
if (finished != task)
{
// 本调用被取消:吞掉 OperationCanceledException 语义,返回 null。
// 注意共享的那次解码仍在后台继续,其它调用方仍能拿到结果。
return null;
}
}
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
}
/// <summary>
/// await 共享 Task 的最后一层兜底。
/// 正常情况这里不会抛(各 Load*CoreAsync 都套了 RunSafeAsync),
/// 但共享 Task 一旦带异常,所有同键调用方都会中招,所以在出口再兜一次,确保公开 API 永不外抛。
/// </summary>
private static async Task<ImageSource?> AwaitSharedTaskAsync(Task<ImageSource?> task)
{
try
{
return await task.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] 共享取图任务异常: {ex}");
return null;
}
}
/// <summary>
/// 服务层统一兜底:取消 → null;其它任何异常(含 COMException / SEHException / 未知)记 Debug 后返回 null。
/// 这里绝不让异常逃出去把 UI 线程打崩。
/// </summary>
private static async Task<ImageSource?> RunSafeAsync(Func<Task<ImageSource?>> work)
{
try
{
return await work().ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// 取消是正常路径(列表滚过去了),静默返回 null
return null;
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] 取图失败: {ex}");
return null;
}
}
/// <summary>逻辑尺寸归一到合法范围(16..1024)。</summary>
private static int NormalizeSize(int size)
{
if (size <= 0) return 32;
if (size < 16) return 16;
if (size > 1024) return 1024;
return size;
}
/// <summary>归一化扩展名:去掉前导点、转小写;空则返回空串。</summary>
private static string NormalizeExtension(string? extension)
{
if (string.IsNullOrWhiteSpace(extension)) return string.Empty;
string ext = extension.Trim();
if (ext.StartsWith('.')) ext = ext[1..];
return ext.ToLowerInvariant();
}
/// <summary>按显示尺寸 × DPI 缩放算出实际渲染像素尺寸(保证高 DPI 不糊)。</summary>
private int ToPixelSize(int logicalSize)
{
double scale = 1.0;
try
{
scale = _scaleProvider();
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] rasterizationScale 取值失败: {ex.Message}");
}
if (double.IsNaN(scale) || double.IsInfinity(scale) || scale <= 0) scale = 1.0;
if (scale > 4.0) scale = 4.0;
int pixels = (int)Math.Round(logicalSize * scale, MidpointRounding.AwayFromZero);
if (pixels < 1) pixels = 1;
if (pixels > 2048) pixels = 2048;
return pixels;
}
/// <summary>
/// 按显示尺寸挑系统图像列表,保证拿到的是「原生该尺寸」的图标,而不是放大出来的。
/// 实测各列表的 HICON 原生尺寸:SHIL_LARGE(0)=32、SHIL_SMALL(1)=16、SHIL_EXTRALARGE(2)=48、SHIL_JUMBO(4)=256。
/// </summary>
private static int SelectImageList(int logicalSize) => logicalSize switch
{
>= 256 => ShellNative.SHIL_JUMBO,
>= 48 => ShellNative.SHIL_EXTRALARGE,
>= 32 => ShellNative.SHIL_LARGE,
_ => ShellNative.SHIL_SYSSMALL,
};
private static string BuildIconKey(string path, bool isDirectory, int logicalSize)
=> (isDirectory ? "dir|" : "file|") + logicalSize.ToString() + "|" + path;
/// <summary>判断是否为已知外壳解析名(shell: 或 ::{CLSID})。</summary>
private static bool IsSpecialParsingName(string path)
=> ShellParsingName.IsShellPrefix(path) || path.StartsWith("::", StringComparison.Ordinal);
}
/// <summary>
/// 极简 LRU 缓存:ConcurrentDictionary 负责高并发读取,LinkedList 记录使用顺序,
/// 超出容量后从链表尾部淘汰最少使用的项。
/// </summary>
/// <typeparam name="T">缓存值类型(本工程里是 ImageSource?)。</typeparam>
internal sealed class LruCache<T>
{
private readonly int _capacity;
private readonly ConcurrentDictionary<string, Entry> _map = new(StringComparer.OrdinalIgnoreCase);
private readonly LinkedList<string> _order = new();
private readonly object _orderGate = new();
internal LruCache(int capacity)
{
_capacity = capacity > 0 ? capacity : 1;
}
private sealed class Entry
{
internal Entry(Lazy<T> value) => Value = value;
internal Lazy<T> Value { get; }
internal LinkedListNode<string>? Node { get; set; }
}
/// <summary>取缓存值(命中即刷新使用顺序)。</summary>
internal bool TryGet(string key, out T value)
{
if (_map.TryGetValue(key, out var entry))
{
Touch(entry);
value = entry.Value.Value;
return true;
}
value = default!;
return false;
}
/// <summary>
/// 取或创建。注意这里返回的是 Lazy&lt;T&gt; 本身:同一键的并发调用方会拿到同一个实例,
/// 因此 T 为 Task 时天然实现了"同键请求合并"。
/// </summary>
internal Lazy<T> GetOrCreate(string key, Func<string, Lazy<T>> factory)
{
var entry = _map.GetOrAdd(key, k => new Entry(factory(k)));
if (entry.Node is null)
{
lock (_orderGate)
{
if (entry.Node is null)
{
entry.Node = _order.AddFirst(key);
Trim();
}
}
}
return entry.Value;
}
internal void Clear()
{
lock (_orderGate)
{
_map.Clear();
_order.Clear();
}
}
/// <summary>把命中的键移到链表头部(最近使用)。</summary>
private void Touch(Entry entry)
{
var node = entry.Node;
if (node is null) return;
lock (_orderGate)
{
if (node.List is null) return;
_order.Remove(node);
_order.AddFirst(node);
}
}
/// <summary>超出容量时从尾部淘汰(调用方已持有 _orderGate)。</summary>
private void Trim()
{
while (_order.Count > _capacity)
{
var last = _order.Last;
if (last is null) return;
_order.RemoveLast();
if (_map.TryRemove(last.Value, out var removed))
{
// 置空节点引用,防止被淘汰的 Entry 仍被 Remove 时的 node.List 判空逻辑误解
removed.Node = null;
}
}
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
namespace FluidExplorer.Services.Icons;
/// <summary>
/// 外壳解析名(parsing name)的小工具。
/// 资源管理器/地址栏里出现的 "shell:RecycleBinFolder"、"{20D04FE0-3AEA-1069-A2D8-08002B30309D}"(此电脑)
/// 都不是文件系统路径,必须先经 SHParseDisplayName 解析成 PIDL 才能取图标。
/// </summary>
internal static class ShellParsingName
{
/// <summary>"shell:" 前缀(大小写不敏感)。</summary>
internal const string ShellPrefix = "shell:";
/// <summary>是不是 "shell:xxx" 形式的解析名。</summary>
internal static bool IsShellPrefix(string? parsingName)
=> !string.IsNullOrEmpty(parsingName)
&& parsingName.StartsWith(ShellPrefix, StringComparison.OrdinalIgnoreCase);
/// <summary>是不是 "::{CLSID}" 形式的解析名(此电脑、回收站等已知外壳对象的经典写法)。</summary>
internal static bool IsGuidPidl(string? parsingName)
=> !string.IsNullOrEmpty(parsingName)
&& parsingName.StartsWith("::", StringComparison.Ordinal);
/// <summary>本工程支持的外壳解析名(取图标时可以走 PIDL 路径)。</summary>
internal static bool IsParsingName(string? parsingName)
=> IsShellPrefix(parsingName) || IsGuidPidl(parsingName);
/// <summary>补全 "shell:" 前缀:传入 "RecycleBinFolder" 也能用。</summary>
internal static string Normalize(string parsingName)
{
if (string.IsNullOrWhiteSpace(parsingName)) return string.Empty;
string trimmed = parsingName.Trim();
return IsParsingName(trimmed) ? trimmed : ShellPrefix + trimmed;
}
// 常用已知外壳文件夹的解析名(全部是 Windows 自带原版对象)
internal const string RecycleBin = "shell:RecycleBinFolder";
internal const string ThisPcClassic = "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}";
internal const string ThisPc = "shell:MyComputerFolder";
internal const string Network = "shell:NetworkPlacesFolder";
internal const string UserProfile = "shell:UserProfile";
internal const string Desktop = "shell:Desktop";
internal const string Downloads = "shell:Downloads";
internal const string Documents = "shell:Personal";
internal const string Pictures = "shell:MyPictures";
internal const string Music = "shell:MyMusic";
internal const string Videos = "shell:MyVideos";
internal const string ControlPanel = "shell:ControlPanelFolder";
}
+169
View File
@@ -0,0 +1,169 @@
using FluidExplorer.Models;
using FluidExplorer.Services.Icons;
using FluidExplorer.ViewModels;
using Microsoft.UI.Dispatching;
namespace FluidExplorer.Services.ItemVisuals;
/// <summary>
/// 列表里每一行的图标/缩略图按需加载:只为真正可见的行发请求,
/// 内置并发上限与去重,滚动再快也不会把外壳图标接口打爆。
/// </summary>
public sealed class ItemVisualService(IIconService icons, DispatcherQueue ui)
{
/// <summary>诊断开关:FLUID_DISABLE_ICONS=1 时完全跳过外壳取图(用于隔离图标路径引发的问题)。</summary>
private static readonly bool IconsDisabled = Environment.GetEnvironmentVariable("FLUID_DISABLE_ICONS") == "1";
private static readonly HashSet<string> ThumbnailExtensions = new(StringComparer.OrdinalIgnoreCase)
{
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "heic", "heif", "avif", "ico", "svg",
"mp4", "mkv", "avi", "mov", "wmv", "m4v", "webm", "mpg", "mpeg", "ts",
"pdf"
};
private readonly SemaphoreSlim _iconGate = new(6, 6);
private readonly SemaphoreSlim _thumbGate = new(3, 3);
private readonly HashSet<string> _inFlight = new(StringComparer.OrdinalIgnoreCase);
private readonly object _sync = new();
public bool IsThumbnailCandidate(string extension) => ThumbnailExtensions.Contains(extension);
/// <summary>为一行请求小图标(列表/详情视图)。</summary>
public void RequestIcon(ExplorerItem item, int size = 32)
{
if (IconsDisabled) return;
var key = "i|" + item.FullPath + "|" + size;
if (!TryBegin(key)) return;
_ = Task.Run(async () =>
{
try
{
var source = await icons.GetIconAsync(item.FullPath, item.IsDirectory, size, CancellationToken.None).ConfigureAwait(false);
if (source is null && !item.IsDirectory)
source = await icons.GetExtensionIconAsync(item.Extension, size, CancellationToken.None).ConfigureAwait(false);
if (source is null) return;
ui.TryEnqueue(() => { try { item.Icon = source; } catch (Exception ex) { App.Log($"设置图标失败: {ex.Message}"); } });
}
catch
{
// 图标失败不影响使用
}
finally
{
_iconGate.Release();
}
});
}
/// <summary>为一行请求缩略图(网格视图);失败则保留图标。</summary>
public void RequestThumbnail(ExplorerItem item, int size)
{
if (IconsDisabled) return;
if (item.IsDirectory || !IsThumbnailCandidate(item.Extension))
{
RequestIcon(item, Math.Min(size, 96));
return;
}
var key = "t|" + item.FullPath + "|" + size;
if (!TryBeginThumb(key)) return;
_ = Task.Run(async () =>
{
try
{
var source = await icons.GetThumbnailAsync(item.FullPath, size, CancellationToken.None).ConfigureAwait(false);
if (source is null)
{
source = await icons.GetIconAsync(item.FullPath, false, Math.Min(size, 96), CancellationToken.None).ConfigureAwait(false);
if (source is null) return;
ui.TryEnqueue(() => { try { item.Icon = source; } catch (Exception ex) { App.Log($"设置图标失败: {ex.Message}"); } });
return;
}
ui.TryEnqueue(() =>
{
try
{
item.HasThumbnail = true;
item.Icon = source;
}
catch (Exception ex)
{
App.Log($"设置缩略图失败: {ex.Message}");
}
});
}
catch
{
// 忽略
}
finally
{
_thumbGate.Release();
}
});
}
public void RequestIcon(SearchResultItem item, int size = 32)
{
if (IconsDisabled) return;
var key = "si|" + item.FullPath + "|" + size;
if (!TryBegin(key)) return;
_ = Task.Run(async () =>
{
try
{
var source = await icons.GetIconAsync(item.FullPath, item.IsDirectory, size, CancellationToken.None).ConfigureAwait(false);
if (source is null && !item.IsDirectory)
source = await icons.GetExtensionIconAsync(item.Hit.Extension, size, CancellationToken.None).ConfigureAwait(false);
if (source is null) return;
ui.TryEnqueue(() => { try { item.Icon = source; } catch (Exception ex) { App.Log($"设置图标失败: {ex.Message}"); } });
}
catch
{
// 忽略
}
finally
{
_iconGate.Release();
}
});
}
/// <summary>导航离开时清掉去重表(已缓存的位图仍在图标服务里)。</summary>
public void ResetPending()
{
lock (_sync) _inFlight.Clear();
}
private bool TryBegin(string key)
{
lock (_sync)
{
if (!_inFlight.Add(key)) return false;
}
if (!_iconGate.Wait(0))
{
lock (_sync) _inFlight.Remove(key);
// 让给其它行,稍后由可见性变化再次触发
return false;
}
return true;
}
private bool TryBeginThumb(string key)
{
lock (_sync)
{
if (!_inFlight.Add(key)) return false;
}
if (!_thumbGate.Wait(0))
{
lock (_sync) _inFlight.Remove(key);
return false;
}
return true;
}
}
+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);
}
}
+83
View File
@@ -0,0 +1,83 @@
using FluidExplorer.Services.Icons;
using FluidExplorer.Services.Operations;
using Microsoft.UI.Xaml.Media;
namespace FluidExplorer.Services;
/// <summary>
/// 降级占位实现:当真实现(外壳图标服务 / 文件操作引擎)在构造阶段抛异常时兜底,
/// 保证主界面还能打开浏览(图标为空、操作给出明确失败提示),而不是整个程序起不来。
/// </summary>
internal sealed class PlaceholderIconService : IIconService
{
public Task<ImageSource?> GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken)
=> Task.FromResult<ImageSource?>(null);
public Task<ImageSource?> GetThumbnailAsync(string path, int size, CancellationToken cancellationToken)
=> Task.FromResult<ImageSource?>(null);
public Task<ImageSource?> GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken)
=> Task.FromResult<ImageSource?>(null);
public ImageSource? GetSpecialFolderIcon(string parsingName, int size) => null;
public void ClearCache() { }
}
internal sealed class PlaceholderOperationService : IFileOperationService
{
private readonly List<FileOperationJob> _jobs = [];
public IReadOnlyList<FileOperationJob> Jobs => _jobs;
public event EventHandler? JobsChanged;
// 占位实现永远不会产生撤销记录,事件显式忽略订阅以避免空事件告警
public event EventHandler? UndoStackChanged
{
add { }
remove { }
}
public Func<ConflictInfo, Task<ConflictResolution>>? ConflictResolver { get; set; }
public bool CanUndo => false;
public string? UndoDescription => null;
private FileOperationJob Fail(FileOperationKind kind, IReadOnlyList<string> sources, string destination)
{
var job = new FileOperationJob
{
Kind = kind,
Title = "文件操作服务不可用",
Sources = sources,
Destination = destination,
State = JobState.Failed,
Error = "文件操作引擎尚未接入。"
};
_jobs.Add(job);
JobsChanged?.Invoke(this, EventArgs.Empty);
return job;
}
public FileOperationJob EnqueueCopy(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
=> Fail(FileOperationKind.Copy, sources, destination);
public FileOperationJob EnqueueMove(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
=> Fail(FileOperationKind.Move, sources, destination);
public FileOperationJob EnqueueDelete(IReadOnlyList<string> paths, bool permanent = false)
=> Fail(FileOperationKind.Delete, paths, string.Empty);
public FileOperationJob EnqueueRename(string path, string newName) => Fail(FileOperationKind.Rename, [path], newName);
public FileOperationJob EnqueueNewFolder(string parentDirectory, string name) => Fail(FileOperationKind.NewFolder, [parentDirectory], name);
public void Pause(Guid jobId) { }
public void Resume(Guid jobId) { }
public void Cancel(Guid jobId) { }
public void ClearFinished() { }
public Task<OperationResult> UndoAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new OperationResult(false, 0, 0, 0, "没有可撤销的操作。"));
public Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
=> Task.FromResult((0L, 0));
}
+77
View File
@@ -0,0 +1,77 @@
namespace FluidExplorer.Services.Search;
public enum IndexState
{
NotStarted,
RequiresElevation,
Building,
Ready,
Watching,
Failed,
Stopped
}
/// <summary>索引里的一条记录(NTFS USN 数据的最小集合)。</summary>
public readonly record struct IndexedEntry(
ulong Frn,
ulong ParentFrn,
string Name,
bool IsDirectory,
long Size,
DateTime ModifiedUtc,
uint Attributes)
{
/// <summary>非 NTFS / 非索引来源(例如回退扫描器)可直接携带完整路径。</summary>
public string? FullPath { get; init; }
public string Extension
{
get
{
if (IsDirectory) return string.Empty;
var i = Name.LastIndexOf('.');
return i > 0 && i < Name.Length - 1 ? Name[(i + 1)..] : string.Empty;
}
}
}
public sealed class IndexStateChangedEventArgs(IndexState state, string? message = null) : EventArgs
{
public IndexState State { get; } = state;
public string? Message { get; } = message;
}
/// <summary>
/// 单卷文件索引:Everything 式体验的核心。
/// 实现必须做到:构建阶段不阻塞调用线程(内部自行使用线程池)、
/// 支持增量更新(USN 日志)、查询使用并行扫描并在毫秒级返回。
/// </summary>
public interface IFileIndex
{
/// <summary>卷根,例如 "C:\"。</summary>
string VolumeRoot { get; }
IndexState State { get; }
long EntryCount { get; }
event EventHandler<IndexStateChangedEventArgs>? StateChanged;
/// <summary>建立初始索引(枚举 MFT / 扫描)。可重复调用,完成后自动转入 Watching。</summary>
Task BuildAsync(IProgress<double>? progress, CancellationToken cancellationToken);
/// <summary>启动增量监听(USN journal),保持索引实时。</summary>
void StartWatching();
void Stop();
/// <summary>
/// 查询。实现约定:
/// 1) 先按 IncludeTerms/Extensions/大小/时间/属性过滤(纯内存操作,必须并行化);
/// 2) 仅当 <see cref="SearchQuery.PathFilter"/> 不为空时,才对已命中的候选调用路径解析;
/// 3) 命中数达到 maxResults 即可提前返回,但不要漏掉更"好"的匹配(短名优先)。
/// </summary>
IEnumerable<IndexedEntry> Query(SearchQuery query, int maxResults, CancellationToken cancellationToken);
/// <summary>把 FRN 解析为完整路径(内部要缓存父链解析结果)。失败返回 false。</summary>
bool TryResolvePath(ulong frn, out string fullPath);
}
+200
View File
@@ -0,0 +1,200 @@
using System.Globalization;
using System.Text;
namespace FluidExplorer.Services.Search;
/// <summary>
/// Everything 风格的查询:空格 = AND,| = OR,"引号" = 精确短语,!term = 排除,
/// 支持 ext: size: dm: dc: folder: file: path: 以及 * ? 通配符。
/// </summary>
public sealed class SearchQuery
{
public string RawText { get; init; } = string.Empty;
public List<string> IncludeTerms { get; } = [];
public List<string> ExcludeTerms { get; } = [];
public List<string> IncludeRegexLike { get; } = []; // 含通配符的项
public List<string> Extensions { get; } = [];
public List<string> ExcludeExtensions { get; } = [];
public bool DirectoriesOnly { get; set; }
public bool FilesOnly { get; set; }
public long? MinSize { get; set; }
public long? MaxSize { get; set; }
public DateTime? ModifiedAfter { get; set; }
public DateTime? ModifiedBefore { get; set; }
public DateTime? CreatedAfter { get; set; }
public string? PathFilter { get; set; }
public bool MatchWholePath { get; set; }
public bool IsEmpty => IncludeTerms.Count == 0 && IncludeRegexLike.Count == 0 && Extensions.Count == 0
&& !DirectoriesOnly && !FilesOnly && MinSize is null && MaxSize is null
&& ModifiedAfter is null && ModifiedBefore is null && CreatedAfter is null;
}
public static class SearchQueryParser
{
public static SearchQuery Parse(string? text)
{
var q = new SearchQuery { RawText = text ?? string.Empty };
if (string.IsNullOrWhiteSpace(text)) return q;
if (text.StartsWith('*') && text.EndsWith('*') && text.Length > 2)
{
q.IncludeTerms.Add(text.Trim('*'));
return q;
}
foreach (var token in Tokenize(text))
{
if (token.Length == 0) continue;
var span = token.AsSpan();
if (span[0] == '!')
{
var t = token[1..].Trim();
if (t.Length == 0) continue;
if (TryExt(t, out var ex)) q.ExcludeExtensions.Add(ex);
else q.ExcludeTerms.Add(t.Trim('"'));
continue;
}
if (TryPrefix(span, "ext:", out var extValue))
{
foreach (var e in extValue.Split([';', ','], StringSplitOptions.RemoveEmptyEntries))
q.Extensions.Add(e.Trim().TrimStart('.').ToLowerInvariant());
continue;
}
if (TryPrefix(span, "size:", out var sizeValue))
{
ParseSize(sizeValue, q);
continue;
}
if (TryPrefix(span, "dm:", out var dm))
{
if (TryParseDate(dm, out var d)) q.ModifiedAfter = d;
continue;
}
if (TryPrefix(span, "dc:", out var dc))
{
if (TryParseDate(dc, out var d)) q.CreatedAfter = d;
continue;
}
if (TryPrefix(span, "folder:", out _) || token.Equals("folder:", StringComparison.OrdinalIgnoreCase))
{
q.DirectoriesOnly = true;
continue;
}
if (token.Equals("file:", StringComparison.OrdinalIgnoreCase))
{
q.FilesOnly = true;
continue;
}
if (TryPrefix(span, "path:", out var pathValue))
{
q.PathFilter = pathValue.Trim('"');
q.MatchWholePath = true;
continue;
}
if (token.Equals("dm:today", StringComparison.OrdinalIgnoreCase)) { q.ModifiedAfter = DateTime.Today; continue; }
var cleaned = token.Trim('"');
if (cleaned.Length == 0) continue;
if (cleaned.IndexOfAny(['*', '?']) >= 0) q.IncludeRegexLike.Add(cleaned);
else q.IncludeTerms.Add(cleaned);
}
return q;
}
private static bool TryExt(string token, out string ext)
{
ext = string.Empty;
if (!TryPrefix(token.AsSpan(), "ext:", out var v)) return false;
ext = v.Trim().TrimStart('.').ToLowerInvariant();
return ext.Length > 0;
}
private static bool TryPrefix(ReadOnlySpan<char> token, string prefix, out string value)
{
value = string.Empty;
if (token.Length <= prefix.Length) return false;
if (!token.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
value = token[prefix.Length..].ToString();
return true;
}
private static void ParseSize(string value, SearchQuery q)
{
value = value.Trim();
if (value.Length == 0) return;
var op = '=';
if (value[0] is '>' or '<' or '=') { op = value[0]; value = value[1..]; }
else if (value.StartsWith(">=", StringComparison.Ordinal)) { op = '>'; value = value[2..]; }
else if (value.StartsWith("<=", StringComparison.Ordinal)) { op = '<'; value = value[2..]; }
double mul = 1;
var lower = value.ToLowerInvariant();
foreach (var (suffix, factor) in SizeSuffixes)
{
if (lower.EndsWith(suffix, StringComparison.Ordinal))
{
mul = factor;
value = value[..^suffix.Length];
break;
}
}
if (!double.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var num)) return;
var bytes = (long)(num * mul);
switch (op)
{
case '>': q.MinSize = bytes; break;
case '<': q.MaxSize = bytes; break;
default: q.MinSize = bytes; q.MaxSize = bytes; break;
}
}
private static readonly (string, double)[] SizeSuffixes =
[
("kb", 1024d), ("mb", 1024d * 1024), ("gb", 1024d * 1024 * 1024), ("tb", 1024d * 1024 * 1024 * 1024),
("k", 1024d), ("m", 1024d * 1024), ("g", 1024d * 1024 * 1024), ("b", 1d)
];
private static bool TryParseDate(string value, out DateTime date)
{
date = default;
var v = value.Trim().ToLowerInvariant();
var now = DateTime.Now;
switch (v)
{
case "today": date = now.Date; return true;
case "yesterday": date = now.Date.AddDays(-1); return true;
case "thisweek": date = now.Date.AddDays(-(int)now.DayOfWeek); return true;
case "thismonth": date = new DateTime(now.Year, now.Month, 1); return true;
case "thisyear": date = new DateTime(now.Year, 1, 1); return true;
}
if (v.EndsWith('d') && int.TryParse(v[..^1], out var days)) { date = now.AddDays(-days); return true; }
if (v.EndsWith('h') && int.TryParse(v[..^1], out var hours)) { date = now.AddHours(-hours); return true; }
if (v.EndsWith('w') && int.TryParse(v[..^1], out var weeks)) { date = now.AddDays(-7 * weeks); return true; }
return DateTime.TryParse(v, CultureInfo.CurrentCulture, DateTimeStyles.None, out date);
}
/// <summary>按空格切词,但保留引号内的空格。</summary>
private static IEnumerable<string> Tokenize(string text)
{
var sb = new StringBuilder();
bool inQuotes = false;
foreach (var ch in text)
{
if (ch == '"')
{
inQuotes = !inQuotes;
sb.Append(ch);
continue;
}
if (!inQuotes && char.IsWhiteSpace(ch))
{
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
continue;
}
sb.Append(ch);
}
if (sb.Length > 0) yield return sb.ToString();
}
}
+237
View File
@@ -0,0 +1,237 @@
using FluidExplorer.Services.Shell;
using FluidExplorer.Services.Search;
using FluidExplorer.Services.FileSystem;
namespace FluidExplorer.Services.Search;
/// <summary>一条搜索结果(对外给 UI 用的扁平结构)。</summary>
public sealed record SearchHit(
string Path,
string Name,
string Directory,
bool IsDirectory,
long Size,
DateTime ModifiedUtc)
{
public string Extension
{
get
{
if (IsDirectory) return string.Empty;
var i = Name.LastIndexOf('.');
return i > 0 && i < Name.Length - 1 ? Name[(i + 1)..].ToLowerInvariant() : string.Empty;
}
}
}
public sealed record SearchOutcome(
IReadOnlyList<SearchHit> Hits,
bool Truncated,
bool UsedIndex,
int IndexedVolumes,
TimeSpan Elapsed,
string? Note = null);
/// <summary>
/// 搜索门面:优先使用 NTFS 索引(毫秒级、全盘),
/// 没有索引时回退为带时间预算的实时枚举(并明确告诉用户"未使用索引")。
/// </summary>
public sealed class SearchService(IFileSystemService fileSystem)
{
private readonly Dictionary<string, IFileIndex> _indexes = new(StringComparer.OrdinalIgnoreCase);
private readonly IFileSystemService _fileSystem = fileSystem;
/// <summary>由 AppServices 注入的具体索引实现工厂(这样本层不依赖具体互操作实现)。</summary>
public Func<string, IFileIndex?>? IndexFactory { get; set; }
public event EventHandler? IndexStateChanged;
public bool HasAnyIndex => _indexes.Count > 0;
public int IndexedVolumeCount => _indexes.Count;
public long TotalIndexedEntries => _indexes.Values.Sum(i => i.EntryCount);
public IndexState AggregateState
{
get
{
if (_indexes.Count == 0) return IndexState.NotStarted;
if (_indexes.Values.Any(i => i.State == IndexState.RequiresElevation)) return IndexState.RequiresElevation;
if (_indexes.Values.Any(i => i.State == IndexState.Building)) return IndexState.Building;
if (_indexes.Values.Any(i => i.State == IndexState.Failed)) return IndexState.Failed;
if (_indexes.Values.All(i => i.State is IndexState.Ready or IndexState.Watching)) return IndexState.Ready;
return IndexState.NotStarted;
}
}
/// <summary>启动指定卷的索引(后台构建,不阻塞 UI)。</summary>
public IFileIndex? EnsureIndex(string volumeRoot)
{
if (_indexes.TryGetValue(volumeRoot, out var existing)) return existing;
var created = IndexFactory?.Invoke(volumeRoot);
if (created is null) return null;
created.StateChanged += (_, _) => IndexStateChanged?.Invoke(this, EventArgs.Empty);
_indexes[volumeRoot] = created;
IndexStateChanged?.Invoke(this, EventArgs.Empty);
return created;
}
public IFileIndex? GetIndex(string volumeRoot)
=> _indexes.TryGetValue(volumeRoot, out var index) ? index : null;
public IReadOnlyCollection<IFileIndex> AllIndexes => _indexes.Values;
/// <summary>为当前所有固定卷建立索引(默认行为:只索引本地固定磁盘,避免扫网络盘)。</summary>
public async Task BuildIndexesAsync(IEnumerable<string> volumeRoots, IProgress<(string Volume, double Progress)>? progress, CancellationToken ct)
{
foreach (var root in volumeRoots)
{
var index = EnsureIndex(root);
if (index is null) continue;
var capturedRoot = root;
var sub = progress is null ? null : new Progress<double>(p => progress.Report((capturedRoot, p)));
await index.BuildAsync(sub, ct).ConfigureAwait(false);
if (index.State is IndexState.Ready or IndexState.Watching) index.StartWatching();
IndexStateChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>执行搜索。Global 走索引;索引不可用时退回实时扫描(并如实告知用户)。</summary>
public async Task<SearchOutcome> SearchAsync(SearchQuery query, SearchScope scope, string? basePath, int maxResults, CancellationToken ct)
{
if (query.IsEmpty) return new SearchOutcome([], false, false, 0, TimeSpan.Zero, "请输入搜索内容");
var sw = System.Diagnostics.Stopwatch.StartNew();
var hits = new List<SearchHit>(maxResults > 0 ? Math.Min(maxResults, 4096) : 1024);
var truncated = false;
var indexedVolumeCount = 0;
if (scope == SearchScope.Global && _indexes.Count > 0)
{
foreach (var (root, index) in _indexes)
{
ct.ThrowIfCancellationRequested();
if (index.State is not (IndexState.Ready or IndexState.Watching)) continue;
indexedVolumeCount++;
var remaining = maxResults - hits.Count;
if (remaining <= 0) { truncated = true; break; }
foreach (var entry in index.Query(query, remaining, ct))
{
var path = entry.FullPath;
if (path is null && !index.TryResolvePath(entry.Frn, out path)) continue;
if (!MatchesExtendedFilters(entry, query, path!)) continue;
hits.Add(new SearchHit(
path!,
entry.Name,
PathHelper.GetParent(path!),
entry.IsDirectory,
entry.Size,
entry.ModifiedUtc == default ? DateTime.MinValue : DateTime.SpecifyKind(entry.ModifiedUtc, DateTimeKind.Utc)));
if (hits.Count >= maxResults) { truncated = true; break; }
}
}
if (indexedVolumeCount > 0)
{
sw.Stop();
return new SearchOutcome(hits, truncated, true, indexedVolumeCount, sw.Elapsed);
}
// 一个可用索引都没有(未建、无管理员权限、非 NTFS):不要返回空结果,
// 而是退回实时扫描,并明确告诉用户为什么慢、怎么才能变快。
}
// 回退:实时枚举(限定范围 + 时间预算 + 结果上限,绝不卡住界面)
var scanRoot = !string.IsNullOrEmpty(basePath) && Directory.Exists(basePath)
? basePath
: SafeSystemDriveRoot();
if (!string.IsNullOrEmpty(scanRoot))
{
var options = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = 0,
MaxRecursionDepth = 32
};
await Task.Run(() =>
{
var budget = TimeSpan.FromSeconds(20);
foreach (var path in Directory.EnumerateFileSystemEntries(scanRoot, "*", options))
{
ct.ThrowIfCancellationRequested();
if (sw.Elapsed > budget) { truncated = true; break; }
string name = PathHelper.GetName(path);
if (!MatchesNameOnly(name, query)) continue;
try
{
var isDir = Directory.Exists(path);
var info = isDir ? null : new FileInfo(path);
hits.Add(new SearchHit(path, name, PathHelper.GetParent(path), isDir,
info?.Length ?? 0, info?.LastWriteTimeUtc ?? DateTime.MinValue));
}
catch
{
hits.Add(new SearchHit(path, name, PathHelper.GetParent(path), false, 0, DateTime.MinValue));
}
if (hits.Count >= maxResults) { truncated = true; break; }
}
}, ct).ConfigureAwait(false);
}
sw.Stop();
var note = _indexes.Count == 0
? "未启用 NTFS 索引,本次为实时扫描(可在设置中开启索引以获得毫秒级全盘搜索)"
: $"索引尚未就绪,已在「{scanRoot}」实时扫描。以管理员身份重启可建立 NTFS 索引,全盘搜索将提升到毫秒级";
return new SearchOutcome(hits, truncated, false, 0, sw.Elapsed, note);
}
private static string? SafeSystemDriveRoot()
{
try
{
var root = Path.GetPathRoot(Environment.SystemDirectory);
return string.IsNullOrEmpty(root) ? null : root;
}
catch
{
return null;
}
}
private static bool MatchesExtendedFilters(IndexedEntry entry, SearchQuery query, string path)
{
if (query.MinSize is { } min && entry.Size >= 0 && entry.Size < min) return false;
if (query.MaxSize is { } max && entry.Size >= 0 && entry.Size > max) return false;
if (!string.IsNullOrEmpty(query.PathFilter))
{
if (!path.Contains(query.PathFilter, StringComparison.OrdinalIgnoreCase)) return false;
}
if (query.MatchWholePath && query.IncludeTerms.Count > 0)
{
// 名字里已经命中就不必再看路径;名字没命中的,允许整条路径命中
foreach (var term in query.IncludeTerms)
{
if (entry.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) continue;
if (path.Contains(term, StringComparison.OrdinalIgnoreCase)) continue;
return false;
}
}
return true;
}
private static bool MatchesNameOnly(string name, SearchQuery query)
{
foreach (var term in query.IncludeTerms)
if (!name.Contains(term, StringComparison.OrdinalIgnoreCase)) return false;
foreach (var bad in query.ExcludeTerms)
if (name.Contains(bad, StringComparison.OrdinalIgnoreCase)) return false;
if (query.Extensions.Count > 0)
{
var ext = Path.GetExtension(name).TrimStart('.').ToLowerInvariant();
if (!query.Extensions.Contains(ext)) return false;
}
return true;
}
}
@@ -0,0 +1,243 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
namespace FluidExplorer.Services.Search.Usn;
/// <summary>
/// 增量监听的<b>回退通道</b>:ReadDirectoryChangesW。
///
/// 什么情况下用:USN 变更日志不可用(卷上没日志且没权限创建、被策略禁用、日志刚被删除等)。
/// 这条路径不需要任何特殊权限,普通用户也能跑,从而保证「索引实时」这个卖点不落空。
///
/// 与 USN 的差别与应对:
/// * RDCW 只给相对<b>路径</b>,不给 FRN。这里用 CreateFileW(FILE_READ_ATTRIBUTES) +
/// GetFileInformationByHandle 取句柄上的 FileIndex —— 它与 USN 的 FRN 完全同口径
/// (低 48 位记录号 + 高 16 位序列号),因此能直接命中同一个索引条目。
/// * 新增/改名/内容变化都伴随着文件仍然存在,可以 stat 出来 → 直接 upsert。
/// * 删除只剩一个路径(stat 必然失败),无法反查 FRN。应对:把受影响的<b>目录</b>记下来,
/// 批处理结束后对该目录做一次「磁盘现状 vs 索引」的对账(<see cref="UsnVolumeIndex.ResyncDirectoryFromDisk"/>),
/// 只把索引里存在、磁盘上已消失的孩子打墓碑。对账按目录去重、每轮有上限,代价可控。
/// </summary>
internal sealed class DirectoryChangeWatcher
{
private const int ReadBufferSize = 64 * 1024;
/// <summary>每轮最多对账多少个目录,防止一次批量删除把 CPU 打满。</summary>
private const int MaxResyncDirectoriesPerPass = 256;
private static readonly uint NotifyFilter =
UsnNative.FILE_NOTIFY_CHANGE_FILE_NAME |
UsnNative.FILE_NOTIFY_CHANGE_DIR_NAME |
UsnNative.FILE_NOTIFY_CHANGE_SIZE |
UsnNative.FILE_NOTIFY_CHANGE_LAST_WRITE |
UsnNative.FILE_NOTIFY_CHANGE_ATTRIBUTES;
private readonly UsnVolumeIndex _index;
private readonly string _root;
private readonly ConcurrentDictionary<string, ulong> _directoryFrnCache = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _pendingResync = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _resyncScratch = [];
private SafeFileHandle? _handle;
private volatile bool _stopRequested;
internal DirectoryChangeWatcher(UsnVolumeIndex index)
{
_index = index;
_root = index.VolumeRoot;
}
/// <summary>打断阻塞中的 ReadDirectoryChangesW:置停止标志 + 关目录句柄(双保险)。</summary>
internal void RequestStop()
{
_stopRequested = true;
Interlocked.Exchange(ref _handle, null)?.Dispose();
}
/// <summary>打开卷根目录句柄。返回 false 时 <paramref name="error"/> 给出中文原因。</summary>
internal bool TryOpen(out string? error)
{
error = null;
var handle = UsnNative.CreateFileW(
_root,
UsnNative.FILE_LIST_DIRECTORY,
UsnNative.FILE_SHARE_READ | UsnNative.FILE_SHARE_WRITE | UsnNative.FILE_SHARE_DELETE,
IntPtr.Zero,
UsnNative.OPEN_EXISTING,
UsnNative.FILE_FLAG_BACKUP_SEMANTICS, // 打开目录必须带这个
IntPtr.Zero);
if (handle.IsInvalid)
{
int openError = Marshal.GetLastWin32Error();
handle.Dispose();
error = UsnNative.DescribeError(openError, $"无法以 FILE_LIST_DIRECTORY 打开 {_root}");
return false;
}
Volatile.Write(ref _handle, handle);
return true;
}
/// <summary>阻塞式监听循环,直到 <see cref="RequestStop"/> 或句柄被关闭。</summary>
internal void Run()
{
var handle = Volatile.Read(ref _handle);
if (handle is null) return;
var buffer = new byte[ReadBufferSize];
try
{
while (!_stopRequested)
{
uint returned;
bool ok;
unsafe
{
fixed (byte* p = buffer)
{
ok = UsnNative.ReadDirectoryChangesW(
handle, p, (uint)buffer.Length, true, NotifyFilter, out returned, IntPtr.Zero, IntPtr.Zero);
}
}
if (!ok)
{
int readError = Marshal.GetLastWin32Error();
if (_stopRequested
|| readError is UsnNative.ERROR_OPERATION_ABORTED or UsnNative.ERROR_CANCELLED or UsnNative.ERROR_INVALID_HANDLE)
{
break;
}
// 单次失败不放弃:报给 UI 后继续(例如枚举期间目录被临时独占)
_index.ReportWatchIssue($"ReadDirectoryChangesW 出错:{UsnNative.DescribeError(readError)}");
continue;
}
if (returned == 0)
{
// 变更缓冲区溢出:期间的事件已经丢了,只能提示 UI 做一次重建
_index.ReportWatchIssue("变更缓冲区溢出,部分变更已丢失,建议重建索引以保持一致。");
continue;
}
ProcessEvents(buffer.AsSpan(0, (int)returned));
FlushPendingResync();
}
}
finally
{
Interlocked.Exchange(ref _handle, null)?.Dispose();
}
}
private void ProcessEvents(ReadOnlySpan<byte> buffer)
{
int offset = 0;
while (true)
{
if (offset + UsnNative.FileNotifyInformation.HeaderSize > buffer.Length) break;
uint nextEntry = BinaryPrimitives.ReadUInt32LittleEndian(buffer[offset..]);
uint action = BinaryPrimitives.ReadUInt32LittleEndian(buffer[(offset + 4)..]);
int nameBytes = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer[(offset + 8)..]);
int nameOffset = offset + UsnNative.FileNotifyInformation.HeaderSize;
if (nameBytes <= 0 || nameOffset + nameBytes > buffer.Length) break;
// 文件名是相对被监听目录(这里是卷根)的路径,形如 "Users\Public\x.txt",不以 NUL 结尾
var relative = MemoryMarshal.Cast<byte, char>(buffer.Slice(nameOffset, nameBytes));
Handle(relative, action);
if (nextEntry == 0) break;
offset += (int)nextEntry;
}
}
private void Handle(ReadOnlySpan<char> relative, uint action)
{
if (relative.Length == 0) return;
var fullPath = string.Concat(_root, relative);
switch (action)
{
case UsnNative.FILE_ACTION_ADDED:
case UsnNative.FILE_ACTION_RENAMED_NEW_NAME:
case UsnNative.FILE_ACTION_MODIFIED:
UpsertFromDisk(fullPath);
break;
case UsnNative.FILE_ACTION_REMOVED:
case UsnNative.FILE_ACTION_RENAMED_OLD_NAME:
QueueDirectoryResync(fullPath);
break;
}
}
/// <summary>按路径 stat 出 FRN + 元数据,然后按 FRN 落入索引(存在则更新,不存在则新增)。</summary>
private void UpsertFromDisk(string fullPath)
{
if (!UsnNative.TryStatPath(fullPath, out var info)) return; // 文件已再次消失等,忽略
var name = Path.GetFileName(fullPath.AsSpan());
if (name.Length == 0) return;
ulong parentFrn = 0;
var parentPath = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(parentPath) && !parentPath.Equals(_root, StringComparison.OrdinalIgnoreCase))
{
parentFrn = GetDirectoryFrn(parentPath);
}
bool isDirectory = (info.FileAttributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
_index.UpsertFromFileSystem(
info.FileIndex,
parentFrn,
name,
isDirectory,
isDirectory ? 0 : info.FileSize,
UsnVolumeIndex.FileTimeToTicks(info.LastWriteTime.ToInt64()),
info.FileAttributes);
if (isDirectory) _directoryFrnCache[fullPath] = UsnNative.NormalizeFrn(info.FileIndex);
}
private ulong GetDirectoryFrn(string directoryPath)
{
if (_directoryFrnCache.TryGetValue(directoryPath, out var cached)) return cached;
if (!UsnNative.TryStatPath(directoryPath, out var info)) return 0;
var frn = UsnNative.NormalizeFrn(info.FileIndex);
_directoryFrnCache[directoryPath] = frn;
return frn;
}
private void QueueDirectoryResync(string fullPath)
{
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory)) _pendingResync.Add(directory);
}
private void FlushPendingResync()
{
if (_pendingResync.Count == 0) return;
_resyncScratch.Clear();
foreach (var directory in _pendingResync)
{
_resyncScratch.Add(directory);
if (_resyncScratch.Count >= MaxResyncDirectoriesPerPass) break;
}
int removed = 0;
foreach (var directory in _resyncScratch)
{
_pendingResync.Remove(directory);
removed += _index.ResyncDirectoryFromDisk(directory);
}
if (removed > 0) _index.ReportWatchIssue(null, removed);
}
}
+212
View File
@@ -0,0 +1,212 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace FluidExplorer.Services.Search.Usn;
/// <summary>
/// 索引的紧凑存储:结构体数组(Struct-of-Arrays)而不是对象数组。
///
/// 每条记录只占 45 字节(8+8+8+8+8+4+1),100 万条约 45MB,
/// 再加上名字池(约 2 字节/字符)就构成整个索引;绝无 per-entry 的对象头与 string。
///
/// 并发模型(读多写极少):
/// * <see cref="Count"/> 用 volatile 发布:写入方先写满数据,最后自增 Count;读方只需读一次 Count 再顺序访问。
/// * 结构只增不减(删除只打墓碑标志位),因此已经发布的 [0, Count) 区间永远有效。
/// * 容量不足时 <see cref="Grow"/> 采用 copy-on-write,整体替换数组;老快照对正在查询的线程依然合法。
/// </summary>
internal sealed class IndexStore
{
internal const byte FlagDeleted = 0x01;
internal const byte FlagDirectory = 0x02;
internal const byte FlagSizeKnown = 0x04;
internal readonly ulong[] Frn; // 归一化 FRN(低 48 位记录号)
internal readonly ulong[] ParentFrn; // 归一化父目录 FRN
internal readonly long[] NameRef; // NamePool.Pack(偏移, 长度)
internal readonly long[] Size; // -1 = 未知(USN 降级模式)
internal readonly long[] ModifiedTicks; // UTC ticks;0 = 未知
internal readonly uint[] Attributes; // FILE_ATTRIBUTE_*
internal readonly byte[] Flags;
/// <summary>
/// 名字池与数组快照绑定在一起:查询只需抓取一次 store 引用就能得到一致的 (名字池, 数组, 条数)。
/// 关键:扩容(<see cref="Grow"/>)必须<b>复用同一个名字池</b> —— NameRef 里存的是池内偏移,
/// 换池等于让所有老记录的名字全部失效。
/// </summary>
internal readonly NamePool Names;
internal readonly int Capacity;
/// <summary>已发布条数。写入方必须在写完所有字段之后再自增它。</summary>
internal volatile int Count;
internal IndexStore(int capacity) : this(capacity, new NamePool())
{
}
private IndexStore(int capacity, NamePool names)
{
if (capacity < 16) capacity = 16;
Capacity = capacity;
Names = names;
Frn = new ulong[capacity];
ParentFrn = new ulong[capacity];
NameRef = new long[capacity];
Size = new long[capacity];
ModifiedTicks = new long[capacity];
Attributes = new uint[capacity];
Flags = new byte[capacity];
}
private IndexStore(IndexStore old, int capacity) : this(capacity, old.Names)
{
int n = old.Count;
Array.Copy(old.Frn, Frn, n);
Array.Copy(old.ParentFrn, ParentFrn, n);
Array.Copy(old.NameRef, NameRef, n);
Array.Copy(old.Size, Size, n);
Array.Copy(old.ModifiedTicks, ModifiedTicks, n);
Array.Copy(old.Attributes, Attributes, n);
Array.Copy(old.Flags, Flags, n);
Count = n;
}
/// <summary>扩容(copy-on-write)。返回新快照,旧快照仍然可被并发查询安全使用。</summary>
internal static IndexStore Grow(IndexStore old, int minCapacity)
{
int capacity = old.Capacity;
while (capacity < minCapacity) capacity = capacity < 1024 ? capacity * 2 : capacity + (capacity >> 1);
return new IndexStore(old, capacity);
}
internal bool IsDeleted(int i) => (Flags[i] & FlagDeleted) != 0;
internal bool IsDirectory(int i) => (Flags[i] & FlagDirectory) != 0;
/// <summary>读名字(零分配)。</summary>
internal ReadOnlySpan<char> GetName(int i)
{
long nameRef = Volatile.Read(ref NameRef[i]);
return Names.Get(NamePool.UnpackOffset(nameRef), NamePool.UnpackLength(nameRef));
}
}
/// <summary>
/// FRN(低 48 位记录号)→ 索引下标的映射。
///
/// 策略:构建期用普通 Dictionary 最快;构建结束后调用 <see cref="Optimize"/>,
/// 如果记录号足够密集(maxRecord &lt;= 8 * count),就换成“记录号直接寻址”的 int[],
/// 100 万文件只占几 MB(而 Dictionary 要 30~40MB)。
/// 稀疏卷或监听期新增的越界记录号退回到 <see cref="ConcurrentDictionary{TKey,TValue}"/> 侧表。
/// </summary>
internal sealed class FrnMap
{
private const int NoIndex = 0;
private const int DenseSlack = 8;
private Dictionary<ulong, int>? _buildMap;
private int[]? _dense; // 记录号 → 下标+1;0 表示不存在
private ConcurrentDictionary<ulong, int>? _sparse;
internal FrnMap(int estimatedCount)
{
_buildMap = new Dictionary<ulong, int>(Math.Max(16, estimatedCount));
}
internal int Count => _buildMap?.Count ?? _sparse?.Count ?? _dense?.Length ?? 0;
/// <summary>构建期写入(单线程,无锁,最快)。</summary>
internal void AddBuild(ulong frn, int index)
{
_buildMap![UsnNative.NormalizeFrn(frn)] = index;
}
/// <summary>构建完成后调用:把构建期的 Dictionary 压缩成密集数组或稀疏侧表。</summary>
internal void Optimize()
{
var map = _buildMap ?? throw new InvalidOperationException("FrnMap 已经压缩过。");
_buildMap = null;
ulong maxRecord = 0;
foreach (var key in map.Keys)
{
if (key > maxRecord) maxRecord = key;
}
// 密集数组代价 = (maxRecord+1)*4 字节;只要不超过 8 个记录号/条目的开销就值得。
if (map.Count > 0 && maxRecord <= (ulong)map.Count * DenseSlack)
{
var dense = new int[maxRecord + 1];
foreach (var (key, value) in map)
{
dense[key] = value + 1;
}
Volatile.Write(ref _dense, dense);
return;
}
var sparse = new ConcurrentDictionary<ulong, int>();
foreach (var (key, value) in map)
{
sparse[key] = value;
}
Volatile.Write(ref _sparse, sparse);
}
/// <summary>监听期写入(可能并发于查询,必须线程安全)。</summary>
internal void Set(ulong frn, int index)
{
ulong record = UsnNative.NormalizeFrn(frn);
var dense = Volatile.Read(ref _dense);
if (dense is not null && record < (ulong)dense.Length)
{
Volatile.Write(ref dense[record], index + 1);
return;
}
var sparse = _sparse;
if (sparse is null)
{
var created = new ConcurrentDictionary<ulong, int>();
sparse = Interlocked.CompareExchange(ref _sparse, created, null) ?? created;
}
sparse[record] = index;
}
/// <summary>批量覆盖(重建索引时用,单线程)。</summary>
internal void SetUnsafe(ulong frn, int index)
{
ulong record = UsnNative.NormalizeFrn(frn);
var dense = _dense;
if (dense is not null && record < (ulong)dense.Length)
{
dense[record] = index + 1;
return;
}
_sparse![record] = index;
}
internal bool TryGet(ulong frn, out int index)
{
ulong record = UsnNative.NormalizeFrn(frn);
var dense = Volatile.Read(ref _dense);
if (dense is not null && record < (ulong)dense.Length)
{
int slot = Volatile.Read(ref dense[record]);
if (slot == NoIndex)
{
index = -1;
return false;
}
index = slot - 1;
return true;
}
var sparse = Volatile.Read(ref _sparse);
if (sparse is not null && sparse.TryGetValue(record, out index)) return true;
index = -1;
return false;
}
/// <summary>NormizeFrn 的别名,便于调用点表达意图。</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static ulong Normalize(ulong frn) => UsnNative.NormalizeFrn(frn);
}
+354
View File
@@ -0,0 +1,354 @@
using System.Buffers.Binary;
using Microsoft.Win32.SafeHandles;
namespace FluidExplorer.Services.Search.Usn;
/// <summary>从一条 $MFT 文件记录里解出来的关键字段。</summary>
internal struct MftRecordInfo
{
internal bool InUse;
internal bool IsDirectory;
internal long Size; // 数据实大小;目录或未找到 $DATA 时为 -1
internal long ModifiedFileTime; // $STANDARD_INFORMATION 偏移 8(LastDataChangeTime)
internal long CreatedFileTime; // $STANDARD_INFORMATION 偏移 0
internal uint Attributes;
internal ulong ParentRecordNumber; // $FILE_NAME 的父目录记录号(低 48 位)
internal int NameLength; // 字符数;-1 表示没有 $FILE_NAME
internal int NameRecordOffset; // 名字在记录内的字节偏移
internal byte NameNamespace; // 0=POSIX 1=Win32 2=DOS 3=Win32&DOS
}
/// <summary>
/// 原始 $MFT 读取器:绕过 USN 日志,直接读卷上的 MFT 数据来拿“真实文件大小 / 精确时间戳 / 真实属性”。
///
/// 步骤:
/// 1) FSCTL_GET_NTFS_VOLUME_DATA 拿 BytesPerSector / BytesPerCluster / BytesPerFileRecordSegment / MftStartLcn;
/// 2) 直接按字节偏移读 MFT 的第 0 条记录($MFT 自身),做 fixup 修正后解析它的 $DATA(0x80) 属性 run list;
/// 3) 由 run list 得到 $MFT 在卷上的全部簇区间,之后按区间批量 ReadFile 并逐条做 fixup 修正 + 属性解析。
///
/// 权限:ReadFile 卷句柄需要 GENERIC_READ,即必须有管理员权限。拿不到时由调用方降级为纯 USN 索引。
/// </summary>
internal sealed class MftReader
{
private const int ReadBlockSize = 4 * 1024 * 1024;
private const uint AttrStandardInformation = 0x10;
private const uint AttrFileName = 0x30;
private const uint AttrData = 0x80;
private const uint AttrEnd = 0xFFFFFFFF;
private const uint FileRecordSignature = 0x454C4946; // "FILE"
private readonly SafeFileHandle _volume;
private readonly int _bytesPerRecord;
private readonly int _bytesPerSector;
private readonly int _bytesPerCluster;
private readonly (long Start, long Length)[] _extents;
internal long MftValidDataLength { get; }
internal int BytesPerRecord => _bytesPerRecord;
internal int BytesPerSector => _bytesPerSector;
/// <summary>MFT 在卷上的簇区间(已按字节换算),可用于日志与自检。</summary>
internal IReadOnlyList<(long Start, long Length)> Extents => _extents;
private MftReader(SafeFileHandle volume, int bytesPerRecord, int bytesPerSector, int bytesPerCluster,
(long, long)[] extents, long mftValidDataLength)
{
_volume = volume;
_bytesPerRecord = bytesPerRecord;
_bytesPerSector = bytesPerSector;
_bytesPerCluster = bytesPerCluster;
_extents = extents;
MftValidDataLength = mftValidDataLength;
}
/// <summary>
/// 尝试建立 MftReader。失败(非 NTFS、无权限、run list 解析不出来)返回 null 并通过 <paramref name="error"/> 说明原因。
/// </summary>
internal static MftReader? TryCreate(SafeFileHandle volume, in UsnNative.NtfsVolumeDataBuffer vd, out string? error)
{
error = null;
if (vd.BytesPerSector is < 256 or > 65536) { error = $"扇区尺寸异常({vd.BytesPerSector} 字节)"; return null; }
if (vd.BytesPerCluster is < 256 or > 8 * 1024 * 1024) { error = $"簇尺寸异常({vd.BytesPerCluster} 字节)"; return null; }
if (vd.BytesPerFileRecordSegment is < 256 or > 65536 || vd.BytesPerFileRecordSegment % vd.BytesPerSector != 0)
{
error = $"MFT 记录尺寸异常({vd.BytesPerFileRecordSegment} 字节)";
return null;
}
int bytesPerSector = (int)vd.BytesPerSector;
int bytesPerCluster = (int)vd.BytesPerCluster;
int bytesPerRecord = (int)vd.BytesPerFileRecordSegment;
// ---- 1) 读 $MFT 自己的记录(记录号 0),位置 = MftStartLcn 个簇 ----
var record0 = new byte[bytesPerRecord];
long mftStartByte = vd.MftStartLcn * bytesPerCluster;
if (UsnNative.ReadAt(volume, record0, mftStartByte) != bytesPerRecord)
{
error = "无法读取 $MFT 的第一条记录(卷句柄缺少读数据权限,或磁盘未就绪)";
return null;
}
var reader = new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [], vd.MftValidDataLength);
if (!ApplyFixup(record0, bytesPerSector))
{
error = "$MFT 记录 0 的 fixup(USA) 校验失败";
return null;
}
// ---- 2) 解析 $DATA(0x80) 的 run list,得到 MFT 在卷上的簇区间 ----
List<(long, long)> extents = [];
if (reader.TryReadDataRuns(record0, extents) && extents.Count > 0)
{
return new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [.. extents], vd.MftValidDataLength);
}
// ---- 3) 降级:绝大多数卷上 $MFT 是连续的,直接按 MftStartLcn 连续读 ----
long needed = vd.MftValidDataLength > 0 ? vd.MftValidDataLength : vd.NumberSectors * bytesPerSector;
long span = (needed + bytesPerCluster - 1) / bytesPerCluster * bytesPerCluster;
extents.Clear();
extents.Add((mftStartByte, span));
error = "$MFT 的 run list 解析失败,已按“MFT 连续存放”的假设降级读取";
return new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [.. extents], vd.MftValidDataLength);
}
// ================================================================ fixup / USA
/// <summary>
/// 应用 NTFS 的 fixup(Update Sequence Array)修正:
/// 每个扇区最后 2 个字节在磁盘上被换成了 USA 里的“更新序列号”,
/// 必须在解析前用 USA 中保存的真实值逐个还原,否则跨扇区的字段会是垃圾数据。
/// 返回 false 表示 USN 不匹配(记录在读取过程中被改写或已损坏),调用方应跳过该记录。
/// 设计成 static 是为了能被“合成记录”单元测试直接调用(无需真实卷句柄)。
/// </summary>
internal static bool ApplyFixup(Span<byte> record, int bytesPerSector)
{
if (record.Length < 8 || bytesPerSector <= 0) return false;
int usaOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[4..]);
int usaCount = BinaryPrimitives.ReadUInt16LittleEndian(record[6..]);
if (usaCount < 1) return false;
if (usaOffset + usaCount * 2 > record.Length) return false;
// USA 覆盖的扇区数必须与记录尺寸一致
if ((usaCount - 1) * bytesPerSector > record.Length) return false;
ushort usn = BinaryPrimitives.ReadUInt16LittleEndian(record[usaOffset..]);
for (int i = 1; i < usaCount; i++)
{
int pos = i * bytesPerSector - 2;
if (pos + 2 > record.Length) return false;
if (BinaryPrimitives.ReadUInt16LittleEndian(record[pos..]) != usn) return false;
ushort real = BinaryPrimitives.ReadUInt16LittleEndian(record[(usaOffset + i * 2)..]);
BinaryPrimitives.WriteUInt16LittleEndian(record[pos..], real);
}
return true;
}
// ================================================================ 属性解析
/// <summary>
/// 从记录里读取 $DATA(0x80) 的 mapping pairs(run list),换算成卷内字节区间。
/// 这里刻意手写属性链循环而不用委托回调:该路径在构建期会被调用百万次,闭包分配不可接受。
/// </summary>
private bool TryReadDataRuns(ReadOnlySpan<byte> record, List<(long, long)> extents)
{
if (record.Length < 48) return false;
int offset = BinaryPrimitives.ReadUInt16LittleEndian(record[20..]);
int guard = 0;
while (offset >= 24 && offset + 8 <= record.Length && guard++ < 1024)
{
uint type = BinaryPrimitives.ReadUInt32LittleEndian(record[offset..]);
if (type == AttrEnd) break;
uint length = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 4)..]);
if (length < 24 || offset + length > record.Length) return false;
bool nonResident = record[offset + 8] != 0;
if (type != AttrData || !nonResident)
{
offset += (int)length;
continue;
}
int runOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 32)..]);
int end = offset + (int)length;
if (runOffset <= 0 || offset + runOffset >= end) return false;
long lcn = 0;
int p = offset + runOffset;
while (p < end)
{
int header = record[p++];
if (header == 0) break;
int lenSize = header & 0x0F;
int offSize = header >> 4;
if (lenSize == 0 || p + lenSize + offSize > end) break;
long runLength = 0;
for (int i = 0; i < lenSize; i++) runLength |= (long)record[p + i] << (8 * i);
p += lenSize;
long delta = 0;
bool sparse = offSize == 0;
if (!sparse)
{
// 偏移字段是相对上一个 LCN 的“有符号”小端整数,必须做符号扩展
for (int i = 0; i < offSize; i++) delta |= (long)record[p + i] << (8 * i);
long signBit = 1L << (8 * offSize - 1);
if ((delta & signBit) != 0) delta -= signBit << 1;
p += offSize;
lcn += delta;
}
// 稀疏区段(offset 字段宽度为 0)不占物理簇,直接跳过
if (runLength > 0 && !sparse) extents.Add((lcn * _bytesPerCluster, runLength * _bytesPerCluster));
}
return extents.Count > 0;
}
return false;
}
/// <summary>解析一条 MFT 记录的 $STANDARD_INFORMATION(0x10) / $FILE_NAME(0x30) / $DATA(0x80)。</summary>
internal static bool TryParseRecord(Span<byte> record, int bytesPerSector, out MftRecordInfo info)
{
info = default;
info.Size = -1;
info.NameLength = -1;
if (record.Length < 56) return false;
if (BinaryPrimitives.ReadUInt32LittleEndian(record) != FileRecordSignature) return false;
if (!ApplyFixup(record, bytesPerSector)) return false;
ushort flags = BinaryPrimitives.ReadUInt16LittleEndian(record[22..]);
info.InUse = (flags & 0x0001) != 0;
info.IsDirectory = (flags & 0x0002) != 0;
int bestNamespace = -1;
int offset = BinaryPrimitives.ReadUInt16LittleEndian(record[20..]);
int guard = 0;
while (offset >= 24 && offset + 8 <= record.Length && guard++ < 1024)
{
uint type = BinaryPrimitives.ReadUInt32LittleEndian(record[offset..]);
if (type == AttrEnd) break;
uint length = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 4)..]);
if (length < 24 || offset + length > record.Length) break;
bool nonResident = record[offset + 8] != 0;
if (!nonResident && type == AttrStandardInformation)
{
int valueOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 20)..]);
int valueLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]);
int v = offset + valueOffset;
if (valueLength >= 36 && v + 36 <= record.Length)
{
info.CreatedFileTime = BinaryPrimitives.ReadInt64LittleEndian(record[v..]);
info.ModifiedFileTime = BinaryPrimitives.ReadInt64LittleEndian(record[(v + 8)..]);
info.Attributes = BinaryPrimitives.ReadUInt32LittleEndian(record[(v + 32)..]);
}
}
else if (!nonResident && type == AttrFileName)
{
int valueOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 20)..]);
int valueLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]);
int v = offset + valueOffset;
if (valueLength >= 66 && v + 66 <= record.Length)
{
int nameLength = record[v + 64];
int nameSpace = record[v + 65];
// 同一文件可能有多个 $FILE_NAME(硬链接 / 8.3 短名):优先 Win32 系列,跳过纯 DOS 名
bool better = bestNamespace < 0 || (bestNamespace == 2 && nameSpace != 2);
if (better && v + 66 + nameLength * 2 <= record.Length)
{
bestNamespace = nameSpace;
info.ParentRecordNumber = BinaryPrimitives.ReadUInt64LittleEndian(record[v..]) & UsnNative.RecordNumberMask;
info.NameLength = nameLength;
info.NameRecordOffset = v + 66;
info.NameNamespace = (byte)nameSpace;
}
}
}
else if (type == AttrData)
{
if (nonResident)
{
if (offset + 56 <= record.Length)
info.Size = BinaryPrimitives.ReadInt64LittleEndian(record[(offset + 48)..]); // RealSize
}
else
{
info.Size = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]); // 驻留数据的大小 = 值长度
}
}
offset += (int)length;
}
if (info.IsDirectory) info.Size = -1; // 目录没有“文件大小”概念,统一记为未知
return true;
}
// ================================================================ 批量枚举
internal delegate void RecordVisitor(ulong recordNumber, Span<byte> record);
/// <summary>
/// 按区间流式读取整个 MFT,逐条回调(缓冲区复用,不产生 per-record 分配)。
/// <paramref name="maxByteOffset"/> 一般传 MftValidDataLength。
/// </summary>
internal void Enumerate(RecordVisitor visitor, long maxByteOffset, Action<long>? onBytesRead, CancellationToken cancellationToken)
{
var buffer = new byte[Math.Max(ReadBlockSize, _bytesPerRecord * 2)];
long consumed = 0; // 相对 MFT 起点的字节数
foreach (var (start, length) in _extents)
{
long extentRead = 0;
while (extentRead + _bytesPerRecord <= length)
{
cancellationToken.ThrowIfCancellationRequested();
if (maxByteOffset > 0 && consumed >= maxByteOffset) return;
long remaining = Math.Min(length - extentRead, maxByteOffset > 0 ? maxByteOffset - consumed : long.MaxValue);
if (remaining < _bytesPerRecord) return;
// 让每次读取边界都落在整条记录上,避免记录被拆到两次读里
int want = (int)Math.Min(buffer.Length, remaining);
want -= want % _bytesPerRecord;
if (want < _bytesPerRecord) want = _bytesPerRecord;
int got = UsnNative.ReadAt(_volume, buffer.AsSpan(0, want), start + extentRead);
if (got < _bytesPerRecord) return;
got -= got % _bytesPerRecord;
var span = buffer.AsSpan(0, got);
for (int off = 0; off + _bytesPerRecord <= got; off += _bytesPerRecord)
{
ulong recordNumber = (ulong)((consumed + off) / _bytesPerRecord);
visitor(recordNumber, span.Slice(off, _bytesPerRecord));
}
extentRead += got;
consumed += got;
onBytesRead?.Invoke(consumed);
if (got < want) return;
}
if (maxByteOffset > 0 && consumed >= maxByteOffset) return;
}
}
/// <summary>
/// 按记录号读一条 MFT 记录(走 run list 换算物理偏移)。用于增量监听时刷新单个文件的大小/时间。
/// </summary>
internal bool TryReadRecord(ulong recordNumber, Span<byte> destination)
{
if (destination.Length < _bytesPerRecord) return false;
long mftByteOffset = (long)recordNumber * _bytesPerRecord;
foreach (var (start, length) in _extents)
{
if (mftByteOffset < length)
{
int got = UsnNative.ReadAt(_volume, destination[.._bytesPerRecord], start + mftByteOffset);
return got == _bytesPerRecord;
}
mftByteOffset -= length;
}
return false;
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace FluidExplorer.Services.Search.Usn;
/// <summary>
/// 紧凑文件名池 —— 索引“快且省内存”的关键之一。
///
/// 所有文件名以 UTF-16 连续存放在若干 <b>定长块</b>(每块 1&lt;&lt;20 个字符 = 2MB)里,
/// 整个索引里不会为文件名产生任何 string 对象;查询时直接在这块内存上取 ReadOnlySpan&lt;char&gt;。
///
/// 偏移编码:<c>(chunkIndex &lt;&lt; 20) | offsetInChunk</c>,单个 int 即可寻址 2G 字符。
/// 因为块大小固定为 1&lt;&lt;20,而 NTFS 单个文件名最长 255 字符,
/// 所以一个名字永远不会跨越两个块 —— 取值时无需拼接。
///
/// 线程安全:块数组一次性预分配(只写指针不扩容),块本身写入后用 <see cref="Volatile.Write{T}(ref T, T)"/> 发布。
/// 查询线程只会读到“已经完整写入”的块;配合索引条数的发布顺序(先写数据、后写 Count),读侧无需加锁。
/// </summary>
internal sealed class NamePool
{
internal const int ChunkShift = 20;
internal const int ChunkSize = 1 << ChunkShift;
internal const int ChunkMask = ChunkSize - 1;
/// <summary>NameRef 只用 8 位存长度,NTFS 文件名最长 255 字符,正好用满。</summary>
internal const int MaxNameLength = 255;
private const int MaxChunks = 2048; // 2048 * 1M = 2G 字符上限
private readonly char[][] _chunks = new char[MaxChunks][];
private int _chunkCount;
private int _fill; // 当前块已使用字符数
private int _publishedChars;
internal NamePool()
{
AddChunk();
}
/// <summary>池中已发布的字符总数(约等于所有名字长度之和)。</summary>
internal int TotalChars => _publishedChars;
/// <summary>写入一个名字,返回它的池内偏移。空名字返回 0(配合 NameRef 的长度字段仍然无歧义)。</summary>
internal int Add(ReadOnlySpan<char> name)
{
if (name.Length == 0) return 0;
if (name.Length > ChunkSize) name = name[..ChunkSize];
if (_fill + name.Length > ChunkSize) AddChunk();
int chunkIndex = _chunkCount - 1;
int offset = (chunkIndex << ChunkShift) | _fill;
name.CopyTo(_chunks[chunkIndex].AsSpan(_fill));
_fill += name.Length;
_publishedChars = (chunkIndex << ChunkShift) + _fill;
return offset;
}
/// <summary>取回名字。参数非法时返回空 span(绝不抛异常,读侧可能并发读到正在更新的旧值)。</summary>
internal ReadOnlySpan<char> Get(int offset, int length)
{
if (length <= 0 || offset < 0) return default;
int chunkIndex = offset >> ChunkShift;
int inChunk = offset & ChunkMask;
if (chunkIndex >= _chunkCount) return default;
var chunk = Volatile.Read(ref _chunks[chunkIndex]);
if (chunk is null || inChunk + length > ChunkSize) return default;
return chunk.AsSpan(inChunk, length);
}
private void AddChunk()
{
if (_chunkCount >= MaxChunks) throw new InvalidOperationException("文件名池已达到容量上限(2G 字符)。");
var chunk = new char[ChunkSize];
Volatile.Write(ref _chunks[_chunkCount], chunk);
_chunkCount++;
_fill = 0;
}
// ---------------------------------------------------------------- NameRef 打包
/// <summary>把 (偏移, 长度) 打包进一个 long:低 8 位是长度,高 56 位是偏移。</summary>
internal static long Pack(int offset, int length) => ((long)offset << 8) | (uint)(length & 0xFF);
internal static int UnpackOffset(long nameRef) => (int)(nameRef >> 8);
internal static int UnpackLength(long nameRef) => (int)(nameRef & 0xFF);
}
+458
View File
@@ -0,0 +1,458 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace FluidExplorer.Services.Search.Usn;
/// <summary>
/// USN 日志 / NTFS 卷所需的最小 P/Invoke 与结构体集合。
///
/// 布局约定:所有 struct 均为 <see cref="LayoutKind.Sequential"/>,默认采用 x64 自然对齐;
/// 字段顺序与偏移和 Windows SDK 的 winioctl.h / ntifs.h 完全一致。
/// 每个结构体后面都标注了实测字节大小,便于对照 <see cref="Marshal.SizeOf{T}()"/> 校验。
/// </summary>
internal static class UsnNative
{
// ---------------------------------------------------------------- 访问权限 / 打开方式
internal const uint GENERIC_READ = 0x80000000;
internal const uint GENERIC_WRITE = 0x40000000;
internal const uint FILE_READ_DATA = 0x0001;
internal const uint FILE_READ_ATTRIBUTES = 0x0080;
internal const uint FILE_LIST_DIRECTORY = 0x0001;
internal const uint FILE_SHARE_READ = 0x00000001;
internal const uint FILE_SHARE_WRITE = 0x00000002;
internal const uint FILE_SHARE_DELETE = 0x00000004;
internal const uint OPEN_EXISTING = 3;
/// <summary>
/// 打开 <c>\\.\C:</c> 时用的 dwFlagsAndAttributes。
/// 参考实现(Everything)用 FILE_ATTRIBUTE_READONLY:传 FILE_ATTRIBUTE_NORMAL 在部分环境下会开不了卷句柄。
/// </summary>
internal const uint FILE_ATTRIBUTE_READONLY = 0x00000001;
/// <summary>开目录句柄必须带 FILE_FLAG_BACKUP_SEMANTICS,否则 CreateFile 会失败。</summary>
internal const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
/// <summary>顺序扫描提示:读 MFT 时对缓存友好(读一次不再复用)。</summary>
internal const uint FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000;
// ---------------------------------------------------------------- 控制码 CTL_CODE(FILE_DEVICE_FILE_SYSTEM=0x09, ...)
internal const uint FSCTL_ENUM_USN_DATA = 0x000900B3;
internal const uint FSCTL_READ_USN_JOURNAL = 0x000900BB;
internal const uint FSCTL_QUERY_USN_JOURNAL = 0x000900F4;
internal const uint FSCTL_CREATE_USN_JOURNAL = 0x000900E7;
internal const uint FSCTL_DELETE_USN_JOURNAL = 0x000900F8;
internal const uint FSCTL_GET_NTFS_VOLUME_DATA = 0x00090064;
internal const uint FSCTL_GET_NTFS_FILE_RECORD = 0x00090068;
/// <summary>FSCTL_DELETE_USN_JOURNAL 的 DeleteFlags:真正删除日志。</summary>
internal const uint USN_DELETE_FLAG_DELETE = 0x00000001;
internal const uint USN_DELETE_FLAG_DO_NOT_DELETE = 0x00000000;
// ---------------------------------------------------------------- ReadDirectoryChangesW(USN 日志不可用时的回退监听)
internal const uint FILE_NOTIFY_CHANGE_FILE_NAME = 0x00000001;
internal const uint FILE_NOTIFY_CHANGE_DIR_NAME = 0x00000002;
internal const uint FILE_NOTIFY_CHANGE_ATTRIBUTES = 0x00000004;
internal const uint FILE_NOTIFY_CHANGE_SIZE = 0x00000008;
internal const uint FILE_NOTIFY_CHANGE_LAST_WRITE = 0x00000010;
internal const uint FILE_ACTION_ADDED = 0x00000001;
internal const uint FILE_ACTION_REMOVED = 0x00000002;
internal const uint FILE_ACTION_MODIFIED = 0x00000003;
internal const uint FILE_ACTION_RENAMED_OLD_NAME = 0x00000004;
internal const uint FILE_ACTION_RENAMED_NEW_NAME = 0x00000005;
// ---------------------------------------------------------------- Win32 错误码
internal const int ERROR_INVALID_FUNCTION = 1;
internal const int ERROR_ACCESS_DENIED = 5;
internal const int ERROR_INVALID_HANDLE = 6;
internal const int ERROR_NOT_READY = 21;
internal const int ERROR_HANDLE_EOF = 38;
internal const int ERROR_NOT_SUPPORTED = 50;
internal const int ERROR_INVALID_PARAMETER = 87;
internal const int ERROR_MORE_DATA = 234;
internal const int ERROR_OPERATION_ABORTED = 995;
internal const int ERROR_NOTIFY_ENUM_DIR = 1022;
internal const int ERROR_JOURNAL_DELETE_IN_PROGRESS = 1178;
internal const int ERROR_JOURNAL_NOT_ACTIVE = 1179;
internal const int ERROR_JOURNAL_ENTRY_DELETED = 1181;
internal const int ERROR_CANCELLED = 1223;
// ---------------------------------------------------------------- 文件属性
internal const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
// ---------------------------------------------------------------- USN 变更原因
internal const uint USN_REASON_DATA_OVERWRITE = 0x00000001;
internal const uint USN_REASON_DATA_EXTEND = 0x00000002;
internal const uint USN_REASON_DATA_TRUNCATION = 0x00000004;
internal const uint USN_REASON_NAMED_DATA_OVERWRITE = 0x00000010;
internal const uint USN_REASON_NAMED_DATA_EXTEND = 0x00000020;
internal const uint USN_REASON_NAMED_DATA_TRUNCATION = 0x00000040;
internal const uint USN_REASON_FILE_CREATE = 0x00000100;
internal const uint USN_REASON_FILE_DELETE = 0x00000200;
internal const uint USN_REASON_EA_CHANGE = 0x00000400;
internal const uint USN_REASON_SECURITY_CHANGE = 0x00000800;
internal const uint USN_REASON_RENAME_OLD_NAME = 0x00001000;
internal const uint USN_REASON_RENAME_NEW_NAME = 0x00002000;
internal const uint USN_REASON_INDEXABLE_CHANGE = 0x00004000;
internal const uint USN_REASON_BASIC_INFO_CHANGE = 0x00008000;
internal const uint USN_REASON_HARD_LINK_CHANGE = 0x00010000;
internal const uint USN_REASON_COMPRESSION_CHANGE = 0x00020000;
internal const uint USN_REASON_ENCRYPTION_CHANGE = 0x00040000;
internal const uint USN_REASON_OBJECT_ID_CHANGE = 0x00080000;
internal const uint USN_REASON_REPARSE_POINT_CHANGE = 0x00100000;
internal const uint USN_REASON_STREAM_CHANGE = 0x00200000;
internal const uint USN_REASON_CLOSE = 0x80000000;
internal const uint USN_REASON_ANY = 0xFFFFFFFF;
// ---------------------------------------------------------------- 线程访问权限(CancelSynchronousIo 需要 THREAD_TERMINATE)
internal const uint THREAD_TERMINATE = 0x0001;
internal static readonly IntPtr INVALID_HANDLE_VALUE = new(-1);
// ================================================================ 结构体
/// <summary>MFT_ENUM_DATA_V0 —— FSCTL_ENUM_USN_DATA 的输入。x64 大小 24。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct MftEnumDataV0
{
internal ulong StartFileReferenceNumber; // 0
internal long LowUsn; // 8 枚举时用 0
internal long HighUsn; // 16 枚举时用 long.MaxValue
}
/// <summary>USN_JOURNAL_DATA_V0 —— FSCTL_QUERY_USN_JOURNAL 的输出。x64 大小 56。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct UsnJournalDataV0
{
internal ulong UsnJournalID; // 0
internal long FirstUsn; // 8
internal long NextUsn; // 16
internal long LowestValidUsn; // 24
internal long MaxUsn; // 32
internal ulong MaximumSize; // 40
internal ulong AllocationDelta; // 48
}
/// <summary>READ_USN_JOURNAL_DATA_V0 —— FSCTL_READ_USN_JOURNAL 的输入。x64 大小 40。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ReadUsnJournalDataV0
{
internal long StartUsn; // 0
internal uint ReasonMask; // 8
internal uint ReturnOnlyOnClose; // 12
internal ulong Timeout; // 16 100ns 单位;0 = 无限等待
internal ulong BytesToWaitFor; // 24 攒够这么多字节再返回(减少唤醒次数)
internal ulong UsnJournalID; // 32
}
/// <summary>
/// USN_RECORD_V2 的固定头部(不含变长文件名)。
///
/// 字段偏移与 Windows SDK 完全一致(x64):RecordLength@0、MajorVersion@4、FRN@8、
/// ParentFrn@16、Usn@24、TimeStamp@32、Reason@40、SourceInfo@44、SecurityId@48、
/// FileAttributes@52、FileNameLength@56、FileNameOffset@58;变长文件名紧跟在第 60 字节之后。
///
/// 显式 Pack=4 的原因:默认 8 字节对齐会把 sizeof 从 60 凑成 64(尾部补 4 字节),
/// 字段偏移虽然不变,但用 sizeof(T) 做缓冲边界判断会凭空多要求 4 字节;
/// Pack=4 下偏移完全不变、sizeof 恰好 60,与磁盘上的紧凑布局严格一致。
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct UsnRecordV2
{
internal uint RecordLength; // 0
internal ushort MajorVersion; // 4 必须 == 2
internal ushort MinorVersion; // 6
internal ulong FileReferenceNumber; // 8 高 16 位是序列号,低 48 位是 MFT 记录号
internal ulong ParentFileReferenceNumber; // 16 同上
internal long Usn; // 24
internal long TimeStamp; // 32 FILETIME(100ns since 1601)
internal uint Reason; // 40
internal uint SourceInfo; // 44
internal uint SecurityId; // 48
internal uint FileAttributes; // 52
internal ushort FileNameLength; // 56 字节数,非字符数
internal ushort FileNameOffset; // 58 相对记录起始的字节偏移
internal const int Size = 60;
}
/// <summary>NTFS_VOLUME_DATA_BUFFER —— FSCTL_GET_NTFS_VOLUME_DATA 的输出。x64 大小 96。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct NtfsVolumeDataBuffer
{
internal long VolumeSerialNumber; // 0
internal long NumberSectors; // 8
internal long TotalClusters; // 16
internal long FreeClusters; // 24
internal long TotalReserved; // 32
internal uint BytesPerSector; // 40
internal uint BytesPerCluster; // 44
internal uint BytesPerFileRecordSegment; // 48
internal uint ClustersPerFileRecordSegment; // 52
internal long MftValidDataLength; // 56
internal long MftStartLcn; // 64
internal long Mft2StartLcn; // 72
internal long MftZoneStart; // 80
internal long MftZoneEnd; // 88
}
/// <summary>NTFS_FILE_RECORD_INPUT_BUFFER —— FSCTL_GET_NTFS_FILE_RECORD 的输入。x64 大小 8。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct NtfsFileRecordInputBuffer
{
internal ulong FileReferenceNumber; // 只取低 48 位记录号
}
/// <summary>NTFS_FILE_RECORD_OUTPUT_BUFFER 的固定头。x64 大小 12(后面紧跟变长记录)。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct NtfsFileRecordOutputBuffer
{
internal ulong FileReferenceNumber; // 0
internal uint FileRecordLength; // 8
}
/// <summary>FSCTL_CREATE_USN_JOURNAL 的输入。x64 大小 16;两个字段都为 0 = 使用系统默认值。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct CreateUsnJournalData
{
internal ulong MaximumSize; // 0 = 系统默认
internal ulong AllocationDelta; // 0 = 系统默认
}
/// <summary>FSCTL_DELETE_USN_JOURNAL 的输入。x64 大小 16(ulong + DWORD + 对齐填充)。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct DeleteUsnJournalData
{
internal ulong UsnJournalID;
internal uint DeleteFlags;
}
/// <summary>
/// FILETIME 的精确布局:两个 DWORD。
/// 刻意不用 long —— 在 LayoutKind.Sequential 下 long 会带来 8 字节对齐,
/// 从而把 BY_HANDLE_FILE_INFORMATION 的后续字段全部顶偏。
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct FileTimeValue
{
internal uint LowDateTime;
internal uint HighDateTime;
internal readonly long ToInt64() => ((long)HighDateTime << 32) | LowDateTime;
}
/// <summary>
/// BY_HANDLE_FILE_INFORMATION(GetFileInformationByHandle 的输出)。x64 大小 52。
/// 其中 FileIndexHigh/Low 合起来就是文件的 64 位 FRN(低 48 位记录号 + 高 16 位序列号),
/// 与 USN_RECORD_V2 里的 FileReferenceNumber 口径一致 —— 这是 RDCW 回退路径能定位索引条目的关键。
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct ByHandleFileInformation
{
internal uint FileAttributes; // 0
internal FileTimeValue CreationTime; // 4
internal FileTimeValue LastAccessTime; // 12
internal FileTimeValue LastWriteTime; // 20
internal uint VolumeSerialNumber; // 28
internal uint FileSizeHigh; // 32
internal uint FileSizeLow; // 36
internal uint NumberOfLinks; // 40
internal uint FileIndexHigh; // 44
internal uint FileIndexLow; // 48
internal readonly ulong FileIndex => ((ulong)FileIndexHigh << 32) | FileIndexLow;
internal readonly long FileSize => ((long)FileSizeHigh << 32) | FileSizeLow;
}
/// <summary>FILE_NOTIFY_INFORMATION 的固定头(变长文件名紧跟其后,UTF-16,不以 NUL 结尾)。</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct FileNotifyInformation
{
internal uint NextEntryOffset; // 0
internal uint Action; // 4
internal uint FileNameLength; // 8,字节数
// WCHAR FileName[1]; // 12
internal const int HeaderSize = 12;
}
// ================================================================ P/Invoke
[DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode, ExactSpelling = true)]
internal static extern SafeFileHandle CreateFileW(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern unsafe bool DeviceIoControl(
SafeFileHandle hDevice,
uint dwIoControlCode,
void* lpInBuffer,
uint nInBufferSize,
void* lpOutBuffer,
uint nOutBufferSize,
out uint lpBytesReturned,
IntPtr lpOverlapped);
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern unsafe bool ReadFile(
SafeFileHandle hFile,
void* lpBuffer,
uint nNumberOfBytesToRead,
out uint lpNumberOfBytesRead,
IntPtr lpOverlapped);
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern unsafe bool SetFilePointerEx(
SafeFileHandle hFile,
long liDistanceToMove,
out long lpNewFilePointer,
uint dwMoveMethod);
internal const uint FILE_BEGIN = 0;
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern IntPtr OpenThread(uint dwDesiredAccess, bool bInheritHandle, uint dwThreadId);
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern uint GetCurrentThreadId();
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern bool CancelSynchronousIo(IntPtr hThread);
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern bool GetFileInformationByHandle(SafeFileHandle hFile, out ByHandleFileInformation lpFileInformation);
/// <summary>
/// 递归监听目录变化。lpOverlapped == NULL 时是同步阻塞调用(靠 CancelSynchronousIo 打断)。
/// 返回 TRUE 且 bytesReturned == 0 表示变更缓冲区溢出(ERROR_NOTIFY_ENUM_DIR),期间的事件已丢失。
/// </summary>
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern unsafe bool ReadDirectoryChangesW(
SafeFileHandle hDirectory,
void* lpBuffer,
uint nBufferLength,
bool bWatchSubtree,
uint dwNotifyFilter,
out uint lpBytesReturned,
IntPtr lpOverlapped,
IntPtr lpCompletionRoutine);
// ================================================================ 托管包装
/// <summary>把读/写缓冲固定后调用 DeviceIoControl;返回 false 时用 <see cref="Marshal.GetLastWin32Error"/> 取错误码。</summary>
internal static unsafe bool Ioctl(SafeFileHandle handle, uint code, ReadOnlySpan<byte> input, Span<byte> output, out int bytesReturned)
{
fixed (byte* pIn = input)
fixed (byte* pOut = output)
{
var ok = DeviceIoControl(
handle, code,
input.Length == 0 ? null : pIn, (uint)input.Length,
output.Length == 0 ? null : pOut, (uint)output.Length,
out var ret, IntPtr.Zero);
bytesReturned = (int)ret;
return ok;
}
}
/// <summary>在卷句柄上做一次带偏移的同步读取(卷句柄偏移 = 卷内绝对字节偏移)。</summary>
internal static unsafe int ReadAt(SafeFileHandle handle, Span<byte> buffer, long offset)
{
if (!SetFilePointerEx(handle, offset, out _, FILE_BEGIN)) return -1;
fixed (byte* p = buffer)
{
if (!ReadFile(handle, p, (uint)buffer.Length, out var read, IntPtr.Zero)) return -1;
return (int)read;
}
}
/// <summary>
/// 按路径取文件的 64 位 FRN 与基本元数据(大小/属性/最后写入时间)。
/// 这是 RDCW 回退监听能定位索引条目的基础:句柄上的 FileIndex 与 USN 的 FRN 同口径。
/// 只要求 FILE_READ_ATTRIBUTES,普通用户也能用(目录需要 FILE_FLAG_BACKUP_SEMANTICS)。
/// </summary>
internal static bool TryStatPath(string path, out ByHandleFileInformation info)
{
info = default;
var handle = CreateFileW(
path,
FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
IntPtr.Zero,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS,
IntPtr.Zero);
if (handle.IsInvalid)
{
handle.Dispose();
return false;
}
try
{
return GetFileInformationByHandle(handle, out info);
}
finally
{
handle.Dispose();
}
}
/// <summary>把 Win32 错误码翻译成中文可读信息,供 UI 直接展示。</summary>
internal static string DescribeError(int error, string? context = null)
{
var text = error switch
{
ERROR_ACCESS_DENIED => "访问被拒绝(需要管理员权限)",
ERROR_INVALID_FUNCTION => "函数不正确(非 NTFS 卷、或无管理员权限调用 NTFS 专属 FSCTL 都会返回它)",
ERROR_NOT_SUPPORTED => "该卷不支持此操作",
ERROR_JOURNAL_NOT_ACTIVE => "该卷未启用 USN 变更日志",
ERROR_JOURNAL_DELETE_IN_PROGRESS => "USN 变更日志正在被删除",
ERROR_JOURNAL_ENTRY_DELETED => "请求的 USN 记录已被删除",
ERROR_HANDLE_EOF => "已到数据末尾",
ERROR_OPERATION_ABORTED => "操作已取消",
ERROR_CANCELLED => "操作已取消",
ERROR_NOT_READY => "卷未就绪",
_ => SafeSystemMessage(error)
};
return context is null ? text : $"{context}:{text}(Win32 错误 {error})";
}
private static string SafeSystemMessage(int error)
{
try
{
return new Win32Exception(error).Message;
}
catch
{
return "未知错误";
}
}
// ================================================================ 记录号归一化
/// <summary>MFT 记录号掩码:FRN 的低 48 位。</summary>
internal const ulong RecordNumberMask = 0x0000_FFFF_FFFF_FFFFUL;
/// <summary>剥掉 FRN 高 16 位的序列号,只保留 MFT 记录号(全索引统一用这个做键)。</summary>
internal static ulong NormalizeFrn(ulong frn) => frn & RecordNumberMask;
}
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
namespace FluidExplorer.Services.Search.Usn;
/// <summary>
/// 通配符匹配器:<c>?</c> 匹配任意单字符,<c>*</c> 匹配任意长度(含空),
/// 大小写不敏感(用固定区域的大小写折叠,不产生任何分配、不受当前区域影响)。
///
/// 实现为经典的“双指针 + 最近星号回溯”,最坏 O(n*m),但对文件名这种短串是纳秒级。
/// </summary>
public static class WildcardMatcher
{
public static bool IsMatch(ReadOnlySpan<char> text, ReadOnlySpan<char> pattern)
{
// ---- 快路径:绝大多数真实查询是 "*.json" / "log*" / "*tmp*" 这类“只有一个/两个通配符”的模式,
// 直接退化成 EndsWith/StartsWith/IndexOf(走的是 BCL 的高度优化实现),比通用回溯快一个数量级。
int stars = 0, questions = 0, firstStar = -1, lastStar = -1;
for (int i = 0; i < pattern.Length; i++)
{
var c = pattern[i];
if (c == '*')
{
stars++;
if (firstStar < 0) firstStar = i;
lastStar = i;
}
else if (c == '?')
{
questions++;
}
}
if (questions == 0)
{
switch (stars)
{
case 0:
return text.Equals(pattern, StringComparison.OrdinalIgnoreCase);
case 1 when firstStar == 0:
return text.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
case 1 when firstStar == pattern.Length - 1:
return text.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase);
case 2 when firstStar == 0 && lastStar == pattern.Length - 1:
return text.IndexOf(pattern[1..^1], StringComparison.OrdinalIgnoreCase) >= 0;
}
}
// ---- 通用路径:双指针 + 最近星号回溯 ----
int t = 0, p = 0;
int starPattern = -1;
int starText = 0;
while (t < text.Length)
{
if (p < pattern.Length && (pattern[p] == '?' || FoldEquals(pattern[p], text[t])))
{
t++;
p++;
}
else if (p < pattern.Length && pattern[p] == '*')
{
// 记下最近的星号位置,先当它匹配空串继续往前走
starPattern = p++;
starText = t;
}
else if (starPattern >= 0)
{
// 回溯:让最近的星号多吃一个字符
p = starPattern + 1;
t = ++starText;
}
else
{
return false;
}
}
while (p < pattern.Length && pattern[p] == '*') p++;
return p == pattern.Length;
}
/// <summary>是否包含通配符。</summary>
public static bool HasWildcard(ReadOnlySpan<char> pattern) =>
pattern.IndexOfAny('*', '?') >= 0;
/// <summary>
/// 返回模式开头连续的字面量前缀(遇到 * 或 ? 为止),用于给通配符命中排优先级。
/// </summary>
public static ReadOnlySpan<char> LiteralPrefix(ReadOnlySpan<char> pattern)
{
int i = 0;
while (i < pattern.Length && pattern[i] is not ('*' or '?')) i++;
return pattern[..i];
}
/// <summary>区域无关的大小写折叠比较(只处理 ASCII 与 BMP 常见情形,足够文件名使用)。</summary>
internal static bool FoldEquals(char a, char b) =>
a == b || char.ToUpperInvariant(a) == char.ToUpperInvariant(b);
/// <summary>模式里是否只剩星号(即 "*" 或 "**" 这类恒真模式)。</summary>
internal static bool IsMatchAll(ReadOnlySpan<char> pattern)
{
foreach (var c in pattern)
{
if (c != '*') return false;
}
return true;
}
}
+116
View File
@@ -0,0 +1,116 @@
using System.Runtime.InteropServices;
using System.Text;
namespace FluidExplorer.Services.Shell;
/// <summary>
/// 用外壳自己的 API(SHGetKnownFolderPath)解析系统文件夹,
/// 保证和资源管理器指向同一批真实位置(含 OneDrive 重定向后的"桌面/文档"等)。
/// </summary>
public static class KnownFolders
{
public static string Profile { get; } = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
public static string Desktop { get; } = Get(FolderId.Desktop);
public static string Documents { get; } = Get(FolderId.Documents);
public static string Downloads { get; } = Get(FolderId.Downloads);
public static string Pictures { get; } = Get(FolderId.Pictures);
public static string Music { get; } = Get(FolderId.Music);
public static string Videos { get; } = Get(FolderId.Videos);
public static string RecycleBin { get; } = @"shell:RecycleBinFolder";
/// <summary>此电脑 / 回收站等虚拟外壳对象的解析名,交给外壳取图标与打开。</summary>
public const string ThisPcParsingName = "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}";
public const string RecycleBinParsingName = "::{645FF040-5081-101B-9F08-00AA002F954E}";
public const string NetworkParsingName = "::{F02C1A0D-BE21-4350-88B0-7367FC96EF3C}";
public const string HomeParsingName = "::{F874310E-B6B7-47DC-BC84-B9E6B38F5903}"; // 主页
public const string GalleryParsingName = "::{E88865EA-0E1C-4E20-9AA6-EDCD0212C87C}"; // 图库
private static string Get(Guid id)
{
try
{
var hr = SHGetKnownFolderPath(ref id, 0, IntPtr.Zero, out var ptr);
if (hr != 0 || ptr == IntPtr.Zero) return string.Empty;
try { return Marshal.PtrToStringUni(ptr) ?? string.Empty; }
finally { Marshal.FreeCoTaskMem(ptr); }
}
catch
{
return string.Empty;
}
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
private static extern int SHGetKnownFolderPath(ref Guid rfid, uint dwFlags, IntPtr hToken, out IntPtr ppszPath);
private static class FolderId
{
public static Guid Desktop = new("B4BFCC3A-DB2C-424C-B029-7FE99A87C641");
public static Guid Documents = new("FDD39AD0-238F-46AF-ADB4-6C85480369C7");
public static Guid Downloads = new("374DE290-123F-4565-9164-39C4925E467B");
public static Guid Pictures = new("33E28130-4E1E-4676-835A-98395C3BC3BB");
public static Guid Music = new("4BD8D571-6D19-48D3-BE97-422220080E43");
public static Guid Videos = new("18989B1D-99B5-455B-841C-AB7C74E4DDFC");
}
}
/// <summary>
/// 卷信息(用于侧边栏"此电脑"和状态栏):显示名、总容量、可用空间、就绪状态。
/// </summary>
public sealed class DriveItem
{
public required string RootPath { get; init; }
public required string DisplayName { get; init; }
public required string VolumeLabel { get; init; }
public required string FileSystem { get; init; }
public long TotalBytes { get; init; }
public long FreeBytes { get; init; }
public int DriveType { get; init; }
public bool IsReady { get; init; }
public double UsedRatio => TotalBytes <= 0 ? 0 : 1.0 - (double)FreeBytes / TotalBytes;
public string CapacityText => !IsReady || TotalBytes <= 0
? "不可用"
: $"{FluidExplorer.Models.FileEntry.FormatSize(FreeBytes)} 可用,共 {FluidExplorer.Models.FileEntry.FormatSize(TotalBytes)}";
public string Glyph => DriveType switch
{
2 => "\uE88E", // 可移动磁盘
3 => "\uEDA2", // 本地磁盘
4 => "\uE8CE", // 网络驱动器
5 => "\uE958", // 光驱
_ => "\uEDA2"
};
public static IReadOnlyList<DriveItem> Enumerate()
{
var list = new List<DriveItem>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
var label = drive.IsReady ? drive.VolumeLabel : string.Empty;
var display = string.IsNullOrWhiteSpace(label)
? (drive.Name.TrimEnd('\\') is { Length: > 0 } letter ? $"本地磁盘 ({letter})" : drive.Name)
: $"{label} ({drive.Name.TrimEnd('\\')})";
list.Add(new DriveItem
{
RootPath = drive.Name,
DisplayName = display,
VolumeLabel = label,
FileSystem = drive.IsReady ? drive.DriveFormat : string.Empty,
TotalBytes = drive.IsReady ? drive.TotalSize : 0,
FreeBytes = drive.IsReady ? drive.TotalFreeSpace : 0,
DriveType = (int)drive.DriveType,
IsReady = drive.IsReady
});
}
catch
{
// 未就绪的驱动器(空读卡器等)直接跳过
}
}
return list;
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Runtime.InteropServices;
namespace FluidExplorer.Services.Shell;
/// <summary>
/// 资源管理器"名称"列用的排序:调用系统 shlwapi 的 StrCmpLogicalW(自然排序,数字按数值比较)。
/// 这样 "文件2" 会排在 "文件10" 前面,和原版体验一致。
/// </summary>
public sealed class NaturalStringComparer : IComparer<string>
{
public static NaturalStringComparer Instance { get; } = new();
public int Compare(string? x, string? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
try
{
return StrCmpLogicalW(x, y);
}
catch
{
return string.Compare(x, y, StringComparison.CurrentCultureIgnoreCase);
}
}
[DllImport("shlwapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
private static extern int StrCmpLogicalW(string psz1, string psz2);
}
+109
View File
@@ -0,0 +1,109 @@
using System.Buffers.Binary;
using System.Text;
namespace FluidExplorer.Services.Shell;
/// <summary>回收站里的一条记录(从 $I 元数据文件解析,含原始路径)。</summary>
public sealed record RecycleBinEntry(
string RecyclePath,
string OriginalPath,
long Size,
DateTime DeletedUtc,
string VolumeRoot)
{
public string Name => Services.FileSystem.PathHelper.GetName(OriginalPath);
public string OriginalDirectory => Services.FileSystem.PathHelper.GetParent(OriginalPath);
public bool IsDirectory => Size == 0 && OriginalPath.Length > 0 && !Path.HasExtension(OriginalPath);
}
/// <summary>
/// 直接读取各卷的 $Recycle.Bin\&lt;SID&gt;\$I* 元数据文件,得到回收站内容与原始路径。
/// 这样"还原/彻底删除"都能由本程序完成(不依赖系统弹窗),也支持一步撤销。
/// </summary>
public static class RecycleBinView
{
public static async Task<IReadOnlyList<RecycleBinEntry>> EnumerateAsync(CancellationToken cancellationToken)
=> await Task.Run(() => Enumerate(cancellationToken), cancellationToken).ConfigureAwait(false);
public static IReadOnlyList<RecycleBinEntry> Enumerate(CancellationToken cancellationToken = default)
{
var results = new List<RecycleBinEntry>();
foreach (var drive in DriveInfo.GetDrives())
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (!drive.IsReady || drive.DriveType != DriveType.Fixed) continue;
var binRoot = Path.Combine(drive.Name, "$Recycle.Bin");
if (!Directory.Exists(binRoot)) continue;
foreach (var sidDir in Directory.EnumerateDirectories(binRoot))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
foreach (var metaFile in Directory.EnumerateFiles(sidDir, "$I*"))
{
cancellationToken.ThrowIfCancellationRequested();
var entry = TryParse(metaFile, drive.Name);
if (entry is not null) results.Add(entry);
}
}
catch
{
// 其他用户的回收站目录通常无权限,跳过
}
}
}
catch
{
// 卷不可读时跳过
}
}
return results;
}
/// <summary>解析单个 $I 元数据文件。</summary>
public static RecycleBinEntry? TryParse(string metaFilePath, string volumeRoot)
{
try
{
var bytes = File.ReadAllBytes(metaFilePath);
if (bytes.Length < 24) return null;
var version = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(0, 8));
var size = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(8, 8));
var fileTime = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(16, 8));
var deleted = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue;
string originalPath;
if (version >= 2 && bytes.Length >= 28)
{
var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(24, 4));
if (length <= 0 || 28 + length * 2 > bytes.Length) return null;
originalPath = Encoding.Unicode.GetString(bytes, 28, length * 2).TrimEnd('\0');
}
else if (version == 1)
{
// 旧格式:路径固定在 0x2C 偏移处的 260 个宽字符
if (bytes.Length < 0x2C + 520) return null;
originalPath = Encoding.Unicode.GetString(bytes, 0x2C, 520).TrimEnd('\0');
}
else
{
return null;
}
if (string.IsNullOrWhiteSpace(originalPath)) return null;
var fileName = Path.GetFileName(metaFilePath);
var recyclePath = Path.Combine(Path.GetDirectoryName(metaFilePath)!, "$R" + fileName[2..]);
return new RecycleBinEntry(recyclePath, originalPath, Math.Max(0, size), deleted, volumeRoot);
}
catch
{
return null;
}
}
}
+223
View File
@@ -0,0 +1,223 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
namespace FluidExplorer.Services.Shell;
/// <summary>
/// 交给 Windows 外壳去做的动作(打开、属性、打开方式、在资源管理器中显示…)。
/// 全部走系统原版行为,保证和资源管理器完全一致。
/// </summary>
public static class ShellActions
{
/// <summary>用默认程序/默认动作打开(= 双击)。</summary>
public static bool Open(string path)
{
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
return true;
}
catch
{
return false;
}
}
/// <summary>打开"打开方式"选择器。</summary>
public static bool OpenWith(string path)
{
try
{
Process.Start(new ProcessStartInfo("rundll32.exe", $"shell32.dll,OpenAs_RunDLL {path}") { UseShellExecute = true });
return true;
}
catch
{
return false;
}
}
/// <summary>调用系统原版"属性"对话框(含只读/隐藏复选框、磁盘清理等)。</summary>
public static bool ShowProperties(IntPtr ownerHwnd, IReadOnlyList<string> paths)
{
if (paths.Count == 0) return false;
var info = new SHELLEXECUTEINFO
{
cbSize = Marshal.SizeOf<SHELLEXECUTEINFO>(),
fMask = SEE_MASK_INVOKEIDLIST | SEE_MASK_FLAG_NO_UI,
hwnd = ownerHwnd,
lpVerb = "properties",
lpFile = paths[0],
nShow = 5
};
if (paths.Count == 1) return ShellExecuteEx(ref info);
// 多选时用外壳的多文件属性对话框
try
{
var files = string.Join('\0', paths) + "\0\0";
var ptr = Marshal.StringToHGlobalUni(files);
try
{
info.lpFile = null;
var psi = new SHFILEINFO();
var hwnd = SHMultiFileProperties(new DataObjectNative { pFiles = ptr }, 0);
_ = hwnd;
_ = psi;
// SHMultiFileProperties 需要 IDataObject 实现,较繁琐;退化为逐项打开第一个的属性
info.lpFile = paths[0];
return ShellExecuteEx(ref info);
}
finally
{
Marshal.FreeHGlobal(ptr);
}
}
catch
{
return false;
}
}
/// <summary>在系统资源管理器中定位并选中该文件(用于"在资源管理器中显示")。</summary>
public static bool RevealInExplorer(string path)
{
try
{
Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true });
return true;
}
catch
{
return false;
}
}
/// <summary>运行对话框式的"运行"入口(用于 shell: 位置)。</summary>
public static bool OpenShellLocation(string parsingName)
{
try
{
Process.Start(new ProcessStartInfo("explorer.exe", parsingName) { UseShellExecute = true });
return true;
}
catch
{
return false;
}
}
/// <summary>把"打开"当作动词执行(用于右键菜单的"打开")。</summary>
public static bool Execute(string path, string verb)
{
var info = new SHELLEXECUTEINFO
{
cbSize = Marshal.SizeOf<SHELLEXECUTEINFO>(),
fMask = SEE_MASK_INVOKEIDLIST | SEE_MASK_FLAG_NO_UI,
lpVerb = verb,
lpFile = path,
nShow = 1
};
return ShellExecuteEx(ref info);
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct SHELLEXECUTEINFO
{
public int cbSize;
public uint fMask;
public IntPtr hwnd;
[MarshalAs(UnmanagedType.LPWStr)] public string? lpVerb;
[MarshalAs(UnmanagedType.LPWStr)] public string? lpFile;
[MarshalAs(UnmanagedType.LPWStr)] public string? lpParameters;
[MarshalAs(UnmanagedType.LPWStr)] public string? lpDirectory;
public int nShow;
public IntPtr hInstApp;
public IntPtr lpIDList;
[MarshalAs(UnmanagedType.LPWStr)] public string? lpClass;
public IntPtr hkeyClass;
public uint dwHotKey;
public IntPtr hIcon;
public IntPtr hProcess;
}
[StructLayout(LayoutKind.Sequential)]
private struct SHFILEINFO
{
public IntPtr hIcon;
public int iIcon;
public uint dwAttributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName;
}
[StructLayout(LayoutKind.Sequential)]
private struct DataObjectNative
{
public IntPtr pFiles;
}
private const uint SEE_MASK_INVOKEIDLIST = 0x0000000C;
private const uint SEE_MASK_FLAG_NO_UI = 0x00000400;
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool ShellExecuteEx(ref SHELLEXECUTEINFO lpExecInfo);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHMultiFileProperties(DataObjectNative pdtobj, uint dwFlags);
/// <summary>把文件放入剪贴板(CF_HDROP,与其他程序互通)。</summary>
public static bool SetClipboardFiles(IReadOnlyList<string> paths, bool cut)
{
try
{
var sb = new StringBuilder();
foreach (var p in paths) sb.Append(p).Append('\0');
sb.Append('\0');
var bytes = Encoding.Unicode.GetBytes(sb.ToString());
var hGlobal = Marshal.AllocHGlobal(bytes.Length + 20);
if (hGlobal == IntPtr.Zero) return false;
// DROPFILES 结构 + 文件名列表
var dropFiles = new byte[20 + bytes.Length];
BitConverter.GetBytes(20).CopyTo(dropFiles, 0); // pFiles 偏移
BitConverter.GetBytes(0).CopyTo(dropFiles, 4); // pt.x
BitConverter.GetBytes(0).CopyTo(dropFiles, 8); // pt.y
BitConverter.GetBytes(0).CopyTo(dropFiles, 12); // fNC
BitConverter.GetBytes(1).CopyTo(dropFiles, 16); // fWide = TRUE
bytes.CopyTo(dropFiles, 20);
Marshal.Copy(dropFiles, 0, hGlobal, dropFiles.Length);
var format = RegisterClipboardFormat(cut ? "Preferred DropEffect" : "Preferred DropEffect");
var effect = new byte[4];
BitConverter.GetBytes(cut ? 2 : 5).CopyTo(effect, 0); // DROPEFFECT_MOVE=2 / COPY=5
var hEffect = Marshal.AllocHGlobal(4);
Marshal.Copy(effect, 0, hEffect, 4);
if (!OpenClipboard(IntPtr.Zero)) { Marshal.FreeHGlobal(hGlobal); Marshal.FreeHGlobal(hEffect); return false; }
try
{
EmptyClipboard();
SetClipboardData(15 /*CF_HDROP*/, hGlobal);
SetClipboardData(format, hEffect);
}
finally
{
CloseClipboard();
}
return true;
}
catch
{
return false;
}
}
[DllImport("user32.dll", SetLastError = true)] private static extern bool OpenClipboard(IntPtr hWndNewOwner);
[DllImport("user32.dll", SetLastError = true)] private static extern bool CloseClipboard();
[DllImport("user32.dll", SetLastError = true)] private static extern bool EmptyClipboard();
[DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern uint RegisterClipboardFormat(string lpszFormat);
}
+267
View File
@@ -0,0 +1,267 @@
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
namespace FluidExplorer.Services.Shell;
/// <summary>
/// Windows 系统原版右键菜单(shell 的 IContextMenu):
/// 就是资源管理器"显示更多选项"里那一套(含第三方扩展、发送到、打开方式、Windows Terminal 等),
/// 直接用系统实现,不自己造菜单项。
/// </summary>
public static class ShellContextMenu
{
public static Task ShowAsync(IntPtr ownerHwnd, IReadOnlyList<string> paths, Windows.Graphics.PointInt32 screenPoint)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
void Run()
{
try
{
ShowCore(ownerHwnd, paths, screenPoint.X, screenPoint.Y);
tcs.TrySetResult();
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
}
if (dispatcher is null || dispatcher.HasThreadAccess) Run();
else dispatcher.TryEnqueue(Run);
return tcs.Task;
}
private static void ShowCore(IntPtr hwnd, IReadOnlyList<string> paths, int x, int y)
{
if (paths.Count == 0) return;
var pidls = new List<IntPtr>();
var allocated = new List<IntPtr>();
IShellFolder? parentFolder = null;
try
{
// 取第一条的父文件夹,作为构建 IContextMenu 的宿主(多选时要求同一父目录)
var firstHr = SHParseDisplayName(paths[0], IntPtr.Zero, out var firstPidl, 0, out _);
if (firstHr != 0 || firstPidl == IntPtr.Zero) return;
allocated.Add(firstPidl);
var bindHr = SHBindToParent(firstPidl, typeof(IShellFolder).GUID, out var folderObj, out var childPidl);
if (bindHr != 0 || folderObj is null) return;
parentFolder = (IShellFolder)folderObj;
var childPidls = new List<IntPtr> { childPidl };
for (var i = 1; i < paths.Count; i++)
{
if (SHParseDisplayName(paths[i], IntPtr.Zero, out var pidl, 0, out _) != 0 || pidl == IntPtr.Zero) continue;
allocated.Add(pidl);
var hr = SHBindToParent(pidl, typeof(IShellFolder).GUID, out var folder, out var child);
if (hr != 0 || folder is null) continue;
if (folder != parentFolder) continue; // 不同目录的多选:忽略额外项(由上层逐个处理)
childPidls.Add(child);
}
var iid = typeof(IContextMenu).GUID;
var uiObjectHr = parentFolder.GetUIObjectOf(hwnd, (uint)childPidls.Count, childPidls.ToArray(), ref iid, IntPtr.Zero, out var contextMenuObj);
if (uiObjectHr != 0 || contextMenuObj is null) return;
var contextMenu = (IContextMenu)contextMenuObj;
var contextMenu2 = contextMenuObj as IContextMenu2;
var contextMenu3 = contextMenuObj as IContextMenu3;
var hMenu = CreatePopupMenu();
if (hMenu == IntPtr.Zero) return;
try
{
const uint CMF_NORMAL = 0x00000000;
const uint CMF_EXTENDEDVERBS = 0x00000100;
var queryHr = contextMenu.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_NORMAL | CMF_EXTENDEDVERBS);
if (queryHr < 0) return;
// 系统菜单里"打开方式/发送到"等子菜单需要转发 owner-draw 消息
using var hook = contextMenu2 is null && contextMenu3 is null
? null
: new MenuMessageHook(hwnd, contextMenu2, contextMenu3);
const uint TPM_RETURNCMD = 0x0100;
const uint TPM_RIGHTBUTTON = 0x0002;
var command = TrackPopupMenuEx(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON, x, y, hwnd, IntPtr.Zero);
if (command <= 0) return;
var info = new CMINVOKECOMMANDINFOEX
{
cbSize = Marshal.SizeOf<CMINVOKECOMMANDINFOEX>(),
fMask = 0x00004000 /*CMIC_MASK_UNICODE*/,
hwnd = hwnd,
lpVerb = (IntPtr)(command - 1),
lpVerbW = (IntPtr)(command - 1),
nShow = 1
};
var invokeHr = contextMenu.InvokeCommand(ref info);
_ = invokeHr;
}
finally
{
DestroyMenu(hMenu);
}
}
finally
{
foreach (var pidl in allocated) Marshal.FreeCoTaskMem(pidl);
if (parentFolder is not null) Marshal.ReleaseComObject(parentFolder);
}
}
/// <summary>把菜单的 owner-draw / init 消息转发给 IContextMenu2/3(子菜单才能正常展开)。</summary>
private sealed class MenuMessageHook : IDisposable
{
private readonly IntPtr _hwnd;
private readonly IContextMenu2? _menu2;
private readonly IContextMenu3? _menu3;
private readonly SubclassProc _proc;
private readonly IntPtr _oldProc;
public MenuMessageHook(IntPtr hwnd, IContextMenu2? menu2, IContextMenu3? menu3)
{
_hwnd = hwnd;
_menu2 = menu2;
_menu3 = menu3;
_proc = HookProc;
_oldProc = SetWindowLongPtr(hwnd, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_proc));
}
private IntPtr HookProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam)
{
switch (msg)
{
case WM_INITMENUPOPUP:
case WM_DRAWITEM:
case WM_MEASUREITEM:
case WM_MENUCHAR:
if (_menu3 is not null)
{
var handled = IntPtr.Zero;
if (_menu3.HandleMenuMsg2(msg, wParam, lParam, out handled) == 0 && handled != IntPtr.Zero) return handled;
}
else if (_menu2 is not null)
{
if (_menu2.HandleMenuMsg(msg, wParam, lParam) == 0) return IntPtr.Zero;
}
break;
}
return CallWindowProc(_oldProc, hwnd, msg, wParam, lParam);
}
public void Dispose()
{
if (_oldProc != IntPtr.Zero) SetWindowLongPtr(_hwnd, GWLP_WNDPROC, _oldProc);
}
}
private const int GWLP_WNDPROC = -4;
private const uint WM_INITMENUPOPUP = 0x0117;
private const uint WM_DRAWITEM = 0x002B;
private const uint WM_MEASUREITEM = 0x002C;
private const uint WM_MENUCHAR = 0x0120;
private delegate IntPtr SubclassProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CMINVOKECOMMANDINFOEX
{
public int cbSize;
public uint fMask;
public IntPtr hwnd;
public IntPtr lpVerb;
public IntPtr lpParameters;
public IntPtr lpDirectory;
public int nShow;
public uint dwHotKey;
public IntPtr hIcon;
public IntPtr lpTitle;
public IntPtr lpVerbW;
public IntPtr lpParametersW;
public IntPtr lpDirectoryW;
public IntPtr lpTitleW;
public POINT ptInvoke;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
[ComImport]
[Guid("000214E6-0000-0000-C000-000000000046")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellFolder
{
[PreserveSig] int ParseDisplayName(IntPtr hwnd, IntPtr pbc, [MarshalAs(UnmanagedType.LPWStr)] string pszDisplayName, out uint pchEaten, out IntPtr ppidl, ref uint pdwAttributes);
[PreserveSig] int EnumObjects(IntPtr hwnd, uint grfFlags, out IntPtr ppenumIDList);
[PreserveSig] int BindToObject(IntPtr pidl, IntPtr pbc, ref Guid riid, out IntPtr ppv);
[PreserveSig] int BindToStorage(IntPtr pidl, IntPtr pbc, ref Guid riid, out IntPtr ppv);
[PreserveSig] int CompareIDs(IntPtr lParam, IntPtr pidl1, IntPtr pidl2);
[PreserveSig] int CreateViewObject(IntPtr hwndOwner, ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetAttributesOf(uint cidl, IntPtr[] apidl, ref uint rgfInOut);
[PreserveSig] int GetUIObjectOf(IntPtr hwndOwner, uint cidl, IntPtr[] apidl, ref Guid riid, IntPtr rgfReserved, out object ppv);
[PreserveSig] int GetDisplayNameOf(IntPtr pidl, uint uFlags, out IntPtr pName);
[PreserveSig] int SetNameOf(IntPtr hwnd, IntPtr pidl, [MarshalAs(UnmanagedType.LPWStr)] string pszName, uint uFlags, out IntPtr ppidlOut);
}
[ComImport]
[Guid("000214E4-0000-0000-C000-000000000046")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IContextMenu
{
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
}
[ComImport]
[Guid("000214F4-0000-0000-C000-000000000046")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IContextMenu2
{
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
[PreserveSig] int HandleMenuMsg(uint uMsg, IntPtr wParam, IntPtr lParam);
}
[ComImport]
[Guid("BCFCE0A0-EC17-11D0-8D10-00A0C90F2719")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IContextMenu3
{
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
[PreserveSig] int HandleMenuMsg(uint uMsg, IntPtr wParam, IntPtr lParam);
[PreserveSig] int HandleMenuMsg2(uint uMsg, IntPtr wParam, IntPtr lParam, out IntPtr plResult);
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHParseDisplayName(string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHBindToParent(IntPtr pidl, Guid riid, out object ppv, out IntPtr ppidlLast);
[DllImport("user32.dll")] private static extern IntPtr CreatePopupMenu();
[DllImport("user32.dll")] private static extern bool DestroyMenu(IntPtr hMenu);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int TrackPopupMenuEx(IntPtr hMenu, uint fuFlags, int x, int y, IntPtr hwnd, IntPtr lptpm);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
}
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Concurrent;
using Microsoft.Win32;
namespace FluidExplorer.Services.Shell;
/// <summary>
/// 用注册表把扩展名映射成资源管理器里显示的友好类型名(例如 "PDF 文档"、"文本文档")。
/// 结果缓存;失败时回退到 "XXX 文件"。
/// </summary>
public static class TypeNameResolver
{
private static readonly ConcurrentDictionary<string, string> Cache = new(StringComparer.OrdinalIgnoreCase);
public static string GetTypeName(string extension, bool isDirectory)
{
if (isDirectory) return "文件夹";
if (string.IsNullOrEmpty(extension)) return "文件";
return Cache.GetOrAdd(extension, static ext =>
{
try
{
var key = "." + ext;
using var extKey = Registry.ClassesRoot.OpenSubKey(key);
if (extKey is null) return $"{ext.ToUpperInvariant()} 文件";
var progId = extKey.GetValue(null) as string;
if (!string.IsNullOrEmpty(progId) && (progId.StartsWith("AppX", StringComparison.OrdinalIgnoreCase)
|| progId.Contains("_", StringComparison.Ordinal)))
{
// AppX/UWP 关联:优先用 "FriendlyTypeName"(本地化资源,直接读字符串)
var friendly = extKey.GetValue("FriendlyTypeName") as string;
if (!string.IsNullOrWhiteSpace(friendly)) return friendly;
}
if (!string.IsNullOrEmpty(progId))
{
using var progKey = Registry.ClassesRoot.OpenSubKey(progId);
if (progKey is not null)
{
var name = progKey.GetValue("FriendlyTypeName") as string ?? progKey.GetValue(null) as string;
if (!string.IsNullOrWhiteSpace(name))
{
// 间接字符串(@dll,-id)无法直接解析,交回扩展名兜底
return name.StartsWith('@') ? $"{ext.ToUpperInvariant()} 文件" : name;
}
}
}
var extFriendly = extKey.GetValue("FriendlyTypeName") as string;
if (!string.IsNullOrWhiteSpace(extFriendly) && !extFriendly.StartsWith('@')) return extFriendly;
}
catch
{
// 注册表不可读(权限/损坏)时静默回退
}
return $"{ext.ToUpperInvariant()} 文件";
});
}
}