Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)..];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<Task>),列表快速滚动时不会重复解码同一张图。
|
||||
/// 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<Task> 模式的落点(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<double>?),见 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<Task> 保证同一个键只会解码一次,其余调用方 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<Task>,但让"本次调用"的取消能立刻返回 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<T> 本身:同一键的并发调用方会拿到同一个实例,
|
||||
/// 因此 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
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
using System.Buffers;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>单个条目(文件/目录)处理后的结果。</summary>
|
||||
internal enum EntryOutcome
|
||||
{
|
||||
Success,
|
||||
Skipped,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拷贝引擎与作业运行器之间的回调边界。
|
||||
/// 引擎本身不认识 FileOperationJob / UI,只通过这个接口上报进度、询问冲突、请求取消,
|
||||
/// 因此可以脱离队列单独测试与复用。
|
||||
/// </summary>
|
||||
internal interface IJobSink
|
||||
{
|
||||
CancellationToken Token { get; }
|
||||
|
||||
bool IsCancellationRequested { get; }
|
||||
|
||||
/// <summary>若作业处于暂停态则挂起,直到继续或取消。每个拷贝块之间调用。</summary>
|
||||
Task WaitIfPausedAsync();
|
||||
|
||||
void AddBytes(long delta);
|
||||
|
||||
void AddCompletedItems(int delta);
|
||||
|
||||
void SetCurrentItem(string path);
|
||||
|
||||
/// <summary>记录一条非致命警告(重解析点跳过、时间戳设置失败等),进 job.Error。</summary>
|
||||
void Warn(string message);
|
||||
|
||||
/// <summary>记录一个失败条目;引擎会继续处理其余文件,绝不中止整批。</summary>
|
||||
void AddFailed(string path, string reason);
|
||||
|
||||
void AddSkipped(int delta = 1);
|
||||
|
||||
/// <summary>
|
||||
/// 记录一次真实的"搬运"以便撤销。
|
||||
/// 语义:<paramref name="newPath"/> 是搬运后的当前位置,<paramref name="originalPath"/> 是原位置;
|
||||
/// 撤销时把 newPath 搬回 originalPath。(同卷移动是 1 条;目录合并移动会产生多条。)
|
||||
/// </summary>
|
||||
void RecordMoveForUndo(string newPath, string originalPath);
|
||||
|
||||
/// <summary>向 UI 询问冲突处理方式。未设置回调 / 非 Ask 策略时由运行器按 Policy 直接决定,不阻塞。</summary>
|
||||
Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info);
|
||||
|
||||
/// <summary>用户选择了"取消",终止整个作业(已完成的部分保留,不回滚)。</summary>
|
||||
void RequestCancel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 文件复制 / 移动 / 删除的核心实现。
|
||||
///
|
||||
/// 关键设计:
|
||||
/// - 所有 Win32 文件 IO 走 \\?\ 长路径前缀(见 <see cref="PathHelper"/>);
|
||||
/// - 1MB 缓冲 + SequentialScan + 异步 IO,块与块之间检查暂停/取消;
|
||||
/// - 单文件失败只重试 3 次(100/300/900ms)后计入失败列表并继续,绝不因单个文件中断整批;
|
||||
/// - 同卷 Move 走 File.Move/Directory.Move(瞬时、不搬字节),跨卷才 Copy+Delete;
|
||||
/// 之所以不用 MoveFileEx/直接调 API:那样虽然也能跨卷搬,但拿不到字节级进度,
|
||||
/// 而"精确进度 + 可暂停/取消"是本引擎的核心诉求。
|
||||
/// </summary>
|
||||
internal static class CopyEngine
|
||||
{
|
||||
/// <summary>拷贝块大小:1MB。</summary>
|
||||
internal const int BufferSize = 1024 * 1024;
|
||||
|
||||
/// <summary>重试间隔(毫秒):共重试 3 次。</summary>
|
||||
private static readonly int[] RetryDelaysMs = [100, 300, 900];
|
||||
|
||||
// ---------------------------------------------------------------- 测量
|
||||
|
||||
/// <summary>
|
||||
/// 递归统计总字节数与总条目数(目录本身也算 1 个条目,和执行阶段的计数口径一致)。
|
||||
/// 不跟随重解析点;无法访问的项跳过。可取消。
|
||||
/// </summary>
|
||||
internal static (long Bytes, int Items) Measure(IReadOnlyList<string> paths, CancellationToken cancellationToken, Action<string>? onWarning = null)
|
||||
{
|
||||
long bytes = 0;
|
||||
var items = 0;
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return (bytes, items);
|
||||
|
||||
var attrs = PathHelper.TryGetAttributes(path);
|
||||
if (attrs is null)
|
||||
{
|
||||
onWarning?.Invoke($"测量时跳过无法访问的项:{path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
onWarning?.Invoke($"测量时跳过重解析点:{path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.Directory) == 0)
|
||||
{
|
||||
bytes += PathHelper.TryGetLength(path);
|
||||
items++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(path);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return (bytes, items);
|
||||
|
||||
var dir = stack.Pop();
|
||||
items++;
|
||||
|
||||
foreach (var child in PathHelper.EnumerateChildrenSafe(dir, onWarning))
|
||||
{
|
||||
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||
if (childAttrs is null) continue;
|
||||
if ((childAttrs & FileAttributes.ReparsePoint) != 0) continue; // 不跟随,避免无限递归
|
||||
|
||||
if ((childAttrs & FileAttributes.Directory) != 0) stack.Push(child);
|
||||
else
|
||||
{
|
||||
bytes += PathHelper.TryGetLength(child);
|
||||
items++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (bytes, items);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 复制
|
||||
|
||||
/// <summary>复制一个条目(文件或目录),内部处理冲突策略。</summary>
|
||||
internal static async Task<EntryOutcome> CopyEntryAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var attrs = PathHelper.TryGetAttributes(source);
|
||||
if (attrs is null)
|
||||
{
|
||||
sink.AddFailed(source, "源不存在或无法访问。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
}
|
||||
|
||||
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||
|
||||
if (PathHelper.Exists(destination))
|
||||
{
|
||||
var resolution = await sink.ResolveConflictAsync(BuildConflictInfo(source, destination)).ConfigureAwait(false);
|
||||
switch (resolution)
|
||||
{
|
||||
case ConflictResolution.Cancel:
|
||||
sink.RequestCancel();
|
||||
return EntryOutcome.Cancelled;
|
||||
|
||||
case ConflictResolution.Skip:
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
|
||||
case ConflictResolution.KeepBoth:
|
||||
destination = PathHelper.MakeUniquePath(destination);
|
||||
break;
|
||||
|
||||
default: // Replace:目录对目录 = 合并;文件对文件 = 先删目标再复制
|
||||
var destinationIsDir = PathHelper.DirectoryExists(destination);
|
||||
if (sourceIsDir && destinationIsDir) break; // 合并,保留目标目录
|
||||
if (sourceIsDir != destinationIsDir)
|
||||
{
|
||||
sink.AddFailed(source, sourceIsDir
|
||||
? "目标位置存在同名文件,无法用文件夹替换文件。"
|
||||
: "目标位置存在同名文件夹,无法用文件替换文件夹。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
var removed = await DeletePermanentAsync(destination, sink, countAsItem: false).ConfigureAwait(false);
|
||||
if (removed != EntryOutcome.Success) return removed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sourceIsDir
|
||||
? await CopyDirectoryAsync(source, destination, sink).ConfigureAwait(false)
|
||||
: await CopyFileAsync(source, destination, sink).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<EntryOutcome> CopyFileAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
long attemptBytes = 0;
|
||||
var ok = await RetryAsync(
|
||||
async () =>
|
||||
{
|
||||
attemptBytes = 0;
|
||||
sink.SetCurrentItem(source);
|
||||
PathHelper.EnsureParentDirectory(destination);
|
||||
await CopyFileCoreAsync(source, destination, sink,
|
||||
n =>
|
||||
{
|
||||
attemptBytes += n; // 重试时用于回退已上报的字节数
|
||||
sink.AddBytes(n); // 真正的进度上报(限频由 sink 负责)
|
||||
}).ConfigureAwait(false);
|
||||
},
|
||||
source,
|
||||
sink,
|
||||
onRetry: () => { if (attemptBytes > 0) sink.AddBytes(-attemptBytes); }).ConfigureAwait(false);
|
||||
|
||||
if (ok)
|
||||
{
|
||||
sink.AddCompletedItems(1);
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
return sink.IsCancellationRequested ? EntryOutcome.Cancelled : EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
private static async Task CopyFileCoreAsync(string source, string destination, IJobSink sink, Action<long> reportBytes)
|
||||
{
|
||||
var sourceExtended = PathHelper.ToExtended(source);
|
||||
var destinationExtended = PathHelper.ToExtended(destination);
|
||||
|
||||
// 目标已存在且只读:必须先去只读,否则 Create 会抛 UnauthorizedAccessException。
|
||||
PathHelper.ClearReadOnly(destination);
|
||||
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
try
|
||||
{
|
||||
await using (var input = new FileStream(sourceExtended, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
await using (var output = new FileStream(destinationExtended, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) throw new OperationCanceledException(sink.Token);
|
||||
|
||||
var read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), sink.Token).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), sink.Token).ConfigureAwait(false);
|
||||
reportBytes(read);
|
||||
}
|
||||
|
||||
await output.FlushAsync(sink.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
// 保留时间戳与属性(只读属性最后设置)。
|
||||
try
|
||||
{
|
||||
var sourceAttrs = PathHelper.TryGetAttributes(source) ?? FileAttributes.Normal;
|
||||
File.SetLastWriteTimeUtc(destinationExtended, File.GetLastWriteTimeUtc(sourceExtended));
|
||||
File.SetCreationTimeUtc(destinationExtended, File.GetCreationTimeUtc(sourceExtended));
|
||||
File.SetAttributes(destinationExtended,
|
||||
sourceAttrs & ~(FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
sink.Warn($"已复制但无法保留时间戳/属性:{destination}({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<EntryOutcome> CopyDirectoryAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
if (IsSameOrSubPathOf(destination, source))
|
||||
{
|
||||
sink.AddFailed(source, "目标路径位于源目录内部,已拒绝执行(会造成无限递归)。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
// 用显式栈做迭代式递归,避免极深目录树耗尽调用栈。
|
||||
var stack = new Stack<(string Source, string Destination)>();
|
||||
stack.Push((source, destination));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var (currentSource, currentDestination) = stack.Pop();
|
||||
|
||||
var created = await RetryAsync(
|
||||
() =>
|
||||
{
|
||||
PathHelper.EnsureParentDirectory(currentDestination);
|
||||
Directory.CreateDirectory(PathHelper.ToExtended(currentDestination));
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
currentDestination,
|
||||
sink).ConfigureAwait(false);
|
||||
|
||||
if (!created) return EntryOutcome.Failed;
|
||||
|
||||
sink.SetCurrentItem(currentDestination);
|
||||
sink.AddCompletedItems(1);
|
||||
|
||||
foreach (var child in PathHelper.EnumerateChildrenSafe(currentSource, sink.Warn))
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||
if (childAttrs is null)
|
||||
{
|
||||
sink.AddFailed(child, "无法读取属性(可能已被删除或拒绝访问)。");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((childAttrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{child}");
|
||||
sink.AddSkipped();
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = PathHelper.Combine(currentDestination, PathHelper.GetFileName(child));
|
||||
if ((childAttrs & FileAttributes.Directory) != 0)
|
||||
{
|
||||
stack.Push((child, target));
|
||||
}
|
||||
else
|
||||
{
|
||||
var outcome = await CopyEntryAsync(child, target, sink).ConfigureAwait(false);
|
||||
if (outcome == EntryOutcome.Cancelled) return EntryOutcome.Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 移动
|
||||
|
||||
/// <summary>移动一个条目,内部处理冲突策略。</summary>
|
||||
internal static async Task<EntryOutcome> MoveEntryAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var attrs = PathHelper.TryGetAttributes(source);
|
||||
if (attrs is null)
|
||||
{
|
||||
sink.AddFailed(source, "源不存在或无法访问。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
}
|
||||
|
||||
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||
|
||||
if (PathHelper.Exists(destination))
|
||||
{
|
||||
var resolution = await sink.ResolveConflictAsync(BuildConflictInfo(source, destination)).ConfigureAwait(false);
|
||||
switch (resolution)
|
||||
{
|
||||
case ConflictResolution.Cancel:
|
||||
sink.RequestCancel();
|
||||
return EntryOutcome.Cancelled;
|
||||
|
||||
case ConflictResolution.Skip:
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
|
||||
case ConflictResolution.KeepBoth:
|
||||
destination = PathHelper.MakeUniquePath(destination);
|
||||
break;
|
||||
|
||||
default:
|
||||
var destinationIsDir = PathHelper.DirectoryExists(destination);
|
||||
if (sourceIsDir && destinationIsDir) break; // 目录对目录:合并(递归搬运子项)
|
||||
if (sourceIsDir != destinationIsDir)
|
||||
{
|
||||
sink.AddFailed(source, sourceIsDir
|
||||
? "目标位置存在同名文件,无法用文件夹替换文件。"
|
||||
: "目标位置存在同名文件夹,无法用文件替换文件夹。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
var removed = await DeletePermanentAsync(destination, sink, countAsItem: false).ConfigureAwait(false);
|
||||
if (removed != EntryOutcome.Success) return removed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceIsDir && PathHelper.DirectoryExists(destination))
|
||||
return await MoveDirectoryMergedAsync(source, destination, sink).ConfigureAwait(false);
|
||||
|
||||
return await MoveSingleAsync(source, destination, sourceIsDir, sink).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 不做冲突询问的移动(重命名、撤销还原用):
|
||||
/// 调用方必须已经保证目标路径不冲突,或已经自行决定好冲突处理方式。
|
||||
/// </summary>
|
||||
internal static async Task<EntryOutcome> MoveDirectAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var attrs = PathHelper.TryGetAttributes(source);
|
||||
if (attrs is null)
|
||||
{
|
||||
sink.AddFailed(source, "源不存在或无法访问。");
|
||||
return EntryOutcome.Failed;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||
sink.AddSkipped();
|
||||
return EntryOutcome.Skipped;
|
||||
}
|
||||
|
||||
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||
|
||||
if (sourceIsDir && PathHelper.DirectoryExists(destination))
|
||||
return await MoveDirectoryMergedAsync(source, destination, sink).ConfigureAwait(false);
|
||||
|
||||
return await MoveSingleAsync(source, destination, sourceIsDir, sink).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<EntryOutcome> MoveSingleAsync(string source, string destination, bool sourceIsDir, IJobSink sink)
|
||||
{
|
||||
// 同卷:File.Move / Directory.Move 是纯元数据操作,瞬时完成,不产生任何字节流量。
|
||||
if (PathHelper.SameVolume(source, destination))
|
||||
{
|
||||
var moved = await RetryAsync(
|
||||
() =>
|
||||
{
|
||||
sink.SetCurrentItem(source);
|
||||
PathHelper.EnsureParentDirectory(destination);
|
||||
var s = PathHelper.ToExtended(source);
|
||||
var d = PathHelper.ToExtended(destination);
|
||||
if (sourceIsDir) Directory.Move(s, d);
|
||||
else File.Move(s, d);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
source,
|
||||
sink).ConfigureAwait(false);
|
||||
|
||||
if (moved)
|
||||
{
|
||||
sink.AddCompletedItems(1);
|
||||
sink.RecordMoveForUndo(destination, source);
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
// 同卷判定失败(例如跨卷挂载点/Junction)时退化:复制成功后删源,仍有字节级进度。
|
||||
sink.Warn($"同卷移动失败,自动改用“复制后删除”:{source}");
|
||||
}
|
||||
|
||||
var copied = sourceIsDir
|
||||
? await CopyDirectoryAsync(source, destination, sink).ConfigureAwait(false)
|
||||
: await CopyEntryAsync(source, destination, sink).ConfigureAwait(false);
|
||||
|
||||
if (copied != EntryOutcome.Success) return copied;
|
||||
|
||||
sink.RecordMoveForUndo(destination, source);
|
||||
|
||||
var deleted = await DeletePermanentAsync(source, sink, countAsItem: false).ConfigureAwait(false);
|
||||
return deleted == EntryOutcome.Success ? EntryOutcome.Success : deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目录合并移动:目标目录已存在时,逐个搬子项。
|
||||
/// 同卷时每个子项都是瞬时的 File.Move;同名子项按冲突策略处理。
|
||||
/// </summary>
|
||||
private static async Task<EntryOutcome> MoveDirectoryMergedAsync(string source, string destination, IJobSink sink)
|
||||
{
|
||||
Directory.CreateDirectory(PathHelper.ToExtended(destination));
|
||||
|
||||
foreach (var child in PathHelper.EnumerateChildrenSafe(source, sink.Warn))
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var target = PathHelper.Combine(destination, PathHelper.GetFileName(child));
|
||||
var outcome = await MoveEntryAsync(child, target, sink).ConfigureAwait(false);
|
||||
if (outcome == EntryOutcome.Cancelled) return EntryOutcome.Cancelled;
|
||||
}
|
||||
|
||||
TryDeleteEmptyDirectory(source);
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 删除
|
||||
|
||||
/// <summary>永久删除(不进回收站)。目录采用"后序迭代删除",逐个条目上报进度。</summary>
|
||||
internal static async Task<EntryOutcome> DeletePermanentAsync(string path, IJobSink sink, bool countAsItem)
|
||||
{
|
||||
var attrs = PathHelper.TryGetAttributes(path);
|
||||
if (attrs is null)
|
||||
{
|
||||
if (countAsItem) sink.AddFailed(path, "路径不存在或无法访问。");
|
||||
return countAsItem ? EntryOutcome.Failed : EntryOutcome.Success;
|
||||
}
|
||||
|
||||
if ((attrs & FileAttributes.Directory) == 0)
|
||||
{
|
||||
var ok = await RetryAsync(
|
||||
() =>
|
||||
{
|
||||
sink.SetCurrentItem(path);
|
||||
PathHelper.ClearReadOnly(path);
|
||||
File.Delete(PathHelper.ToExtended(path));
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
path,
|
||||
sink).ConfigureAwait(false);
|
||||
|
||||
if (!ok) return sink.IsCancellationRequested ? EntryOutcome.Cancelled : EntryOutcome.Failed;
|
||||
if (countAsItem) sink.AddCompletedItems(1);
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
var stack = new Stack<(string Path, bool Expanded)>();
|
||||
stack.Push((path, false));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||
|
||||
var (current, expanded) = stack.Pop();
|
||||
|
||||
if (!expanded)
|
||||
{
|
||||
stack.Push((current, true));
|
||||
foreach (var child in PathHelper.EnumerateChildrenSafe(current, sink.Warn))
|
||||
{
|
||||
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||
if (childAttrs is null) continue;
|
||||
|
||||
// 重解析点:只删链接本身,绝不递归进去(否则会删掉链接目标的内容)。
|
||||
if ((childAttrs & FileAttributes.ReparsePoint) != 0 || (childAttrs & FileAttributes.Directory) == 0)
|
||||
stack.Push((child, true));
|
||||
else
|
||||
stack.Push((child, false));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var isDirectory = PathHelper.DirectoryExists(current);
|
||||
var deleted = await RetryAsync(
|
||||
() =>
|
||||
{
|
||||
sink.SetCurrentItem(current);
|
||||
PathHelper.ClearReadOnly(current);
|
||||
var extended = PathHelper.ToExtended(current);
|
||||
if (isDirectory) Directory.Delete(extended, recursive: false);
|
||||
else File.Delete(extended);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
current,
|
||||
sink).ConfigureAwait(false);
|
||||
|
||||
if (deleted && countAsItem) sink.AddCompletedItems(1);
|
||||
}
|
||||
|
||||
return EntryOutcome.Success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 通用
|
||||
|
||||
/// <summary>重试包装:IOException / UnauthorizedAccessException 重试 3 次(100/300/900ms),
|
||||
/// 仍失败则计入失败列表并返回 false,由调用方继续处理其余文件。</summary>
|
||||
private static async Task<bool> RetryAsync(Func<Task> action, string path, IJobSink sink, Action? onRetry = null)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await action().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (attempt < RetryDelaysMs.Length && IsTransient(ex))
|
||||
{
|
||||
onRetry?.Invoke();
|
||||
try
|
||||
{
|
||||
await Task.Delay(RetryDelaysMs[attempt], sink.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onRetry?.Invoke();
|
||||
sink.AddFailed(path, Describe(ex));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTransient(Exception ex) => ex is IOException or UnauthorizedAccessException;
|
||||
|
||||
private static string Describe(Exception ex) => ex switch
|
||||
{
|
||||
UnauthorizedAccessException => "拒绝访问(文件可能被占用或权限不足)。",
|
||||
DirectoryNotFoundException => "目录不存在(可能已被移动或删除)。",
|
||||
FileNotFoundException => "文件不存在(可能已被移动或删除)。",
|
||||
PathTooLongException => "路径过长。",
|
||||
_ => ex.Message
|
||||
};
|
||||
|
||||
internal static ConflictInfo BuildConflictInfo(string source, string destination)
|
||||
{
|
||||
var sourceAttrs = PathHelper.TryGetAttributes(source) ?? 0;
|
||||
var destinationAttrs = PathHelper.TryGetAttributes(destination) ?? 0;
|
||||
var sourceIsDir = (sourceAttrs & FileAttributes.Directory) != 0;
|
||||
var destinationIsDir = (destinationAttrs & FileAttributes.Directory) != 0;
|
||||
|
||||
return new ConflictInfo
|
||||
{
|
||||
SourcePath = source,
|
||||
DestinationPath = destination,
|
||||
SourceIsDirectory = sourceIsDir,
|
||||
SourceSize = sourceIsDir ? 0 : PathHelper.TryGetLength(source),
|
||||
DestinationSize = destinationIsDir ? 0 : PathHelper.TryGetLength(destination),
|
||||
SourceModifiedUtc = TryGetModifiedUtc(source),
|
||||
DestinationModifiedUtc = TryGetModifiedUtc(destination)
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime TryGetModifiedUtc(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.GetLastWriteTimeUtc(PathHelper.ToExtended(path));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>candidate 是否等于 root 或位于 root 之内(用于拒绝"复制到自身内部")。</summary>
|
||||
internal static bool IsSameOrSubPathOf(string candidate, string root)
|
||||
{
|
||||
var c = (PathHelper.TryGetFullPath(candidate) ?? candidate)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var r = (PathHelper.TryGetFullPath(root) ?? root)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
if (string.Equals(c, r, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return c.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void TryDeleteEmptyDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var extended = PathHelper.ToExtended(path);
|
||||
if (Directory.Exists(extended)) Directory.Delete(extended, recursive: false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 目录里还有没搬走的项(例如同名冲突被 Skip 了):保留,不算失败。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>
|
||||
/// 文件操作引擎(纯 .NET 实现,不引用任何 WinUI 类型,可独立测试与复用)。
|
||||
///
|
||||
/// 线程模型:
|
||||
/// - 所有作业都在后台 worker 上执行,UI 线程只负责入队/暂停/继续/取消,永不阻塞;
|
||||
/// - 默认串行执行(避免多作业同时读写同一块磁盘造成抖动);
|
||||
/// 同卷 Move / 重命名 / 新建文件夹这类"瞬时元数据操作"走并行车道,不占用串行队首;
|
||||
/// - 进度回调按 50ms 限频,避免 UI 事件风暴。
|
||||
/// </summary>
|
||||
public sealed class FileOperationService : IFileOperationService, IDisposable
|
||||
{
|
||||
/// <summary>撤销栈上限:超出后丢弃最旧的条目。</summary>
|
||||
private const int MaxUndoEntries = 30;
|
||||
|
||||
/// <summary>进度上报限频(毫秒)。</summary>
|
||||
private const int ProgressFlushIntervalMs = 50;
|
||||
|
||||
/// <summary>JobsChanged 限频(毫秒):进度类变化不按字节风暴式通知。</summary>
|
||||
private const int JobsChangedThrottleMs = 250;
|
||||
|
||||
private const int MaxErrorLength = 2000;
|
||||
|
||||
private readonly JobQueue _queue;
|
||||
private readonly Stack<UndoEntry> _undoStack = new();
|
||||
private readonly object _undoGate = new();
|
||||
private int _lastJobsChangedTick;
|
||||
private bool _disposed;
|
||||
|
||||
public FileOperationService()
|
||||
{
|
||||
_queue = new JobQueue(ExecuteJobAsync);
|
||||
_queue.Start();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 队列与状态
|
||||
|
||||
public IReadOnlyList<FileOperationJob> Jobs => _queue.Jobs;
|
||||
|
||||
public event EventHandler? JobsChanged
|
||||
{
|
||||
add => _queue.JobsChanged += value;
|
||||
remove => _queue.JobsChanged -= value;
|
||||
}
|
||||
|
||||
/// <summary>UI 设置冲突回调;未设置时 Ask 策略按 KeepBoth(保留两者)处理。</summary>
|
||||
public Func<ConflictInfo, Task<ConflictResolution>>? ConflictResolver { get; set; }
|
||||
|
||||
public void Pause(Guid jobId) => _queue.Pause(jobId);
|
||||
|
||||
public void Resume(Guid jobId) => _queue.Resume(jobId);
|
||||
|
||||
public void Cancel(Guid jobId) => _queue.Cancel(jobId);
|
||||
|
||||
public void ClearFinished() => _queue.ClearFinished();
|
||||
|
||||
/// <summary>进度类变化按 250ms 限频触发 JobsChanged,避免逐字节通知造成 UI 事件风暴。</summary>
|
||||
private void NotifyJobsChangedThrottled()
|
||||
{
|
||||
var now = Environment.TickCount;
|
||||
if (unchecked(now - _lastJobsChangedTick) < JobsChangedThrottleMs) return;
|
||||
_lastJobsChangedTick = now;
|
||||
_queue.Raise();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 入队
|
||||
|
||||
public FileOperationJob EnqueueCopy(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
|
||||
=> EnqueueTransfer(FileOperationKind.Copy, sources, destination, policy);
|
||||
|
||||
public FileOperationJob EnqueueMove(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
|
||||
=> EnqueueTransfer(FileOperationKind.Move, sources, destination, policy);
|
||||
|
||||
public FileOperationJob EnqueueDelete(IReadOnlyList<string> paths, bool permanent = false)
|
||||
{
|
||||
var list = NormalizePaths(paths);
|
||||
var job = new FileOperationJob
|
||||
{
|
||||
Kind = permanent ? FileOperationKind.Delete : FileOperationKind.Recycle,
|
||||
Title = permanent ? $"永久删除 {list.Count} 个项目" : $"删除 {list.Count} 个项目到回收站",
|
||||
Sources = list,
|
||||
PermanentDelete = permanent,
|
||||
Policy = ConflictPolicy.Replace,
|
||||
// 回收站删除由 Shell 一次调用完成一批,拿不到字节进度,只按文件数上报。
|
||||
IsIndeterminate = !permanent
|
||||
};
|
||||
|
||||
_queue.Enqueue(job, instantLane: false);
|
||||
return job;
|
||||
}
|
||||
|
||||
public FileOperationJob EnqueueRename(string path, string newName)
|
||||
{
|
||||
var source = PathHelper.TryGetFullPath(path) ?? path;
|
||||
var job = new FileOperationJob
|
||||
{
|
||||
Kind = FileOperationKind.Rename,
|
||||
Title = $"重命名为“{newName}”",
|
||||
Sources = [source],
|
||||
NewName = newName,
|
||||
Policy = ConflictPolicy.Skip,
|
||||
IsIndeterminate = true
|
||||
};
|
||||
|
||||
_queue.Enqueue(job, instantLane: true);
|
||||
return job;
|
||||
}
|
||||
|
||||
public FileOperationJob EnqueueNewFolder(string parentDirectory, string name)
|
||||
{
|
||||
var parent = PathHelper.TryGetFullPath(parentDirectory) ?? parentDirectory;
|
||||
var job = new FileOperationJob
|
||||
{
|
||||
Kind = FileOperationKind.NewFolder,
|
||||
Title = $"新建文件夹“{name}”",
|
||||
Sources = [parent],
|
||||
Destination = parent,
|
||||
NewName = name,
|
||||
Policy = ConflictPolicy.KeepBoth,
|
||||
IsIndeterminate = true
|
||||
};
|
||||
|
||||
_queue.Enqueue(job, instantLane: true);
|
||||
return job;
|
||||
}
|
||||
|
||||
private FileOperationJob EnqueueTransfer(FileOperationKind kind, IReadOnlyList<string> sources, string destination, ConflictPolicy policy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sources);
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
|
||||
var list = NormalizePaths(sources);
|
||||
var destinationPath = PathHelper.TryGetFullPath(destination) ?? destination;
|
||||
var verb = kind == FileOperationKind.Copy ? "复制" : "移动";
|
||||
|
||||
var job = new FileOperationJob
|
||||
{
|
||||
Kind = kind,
|
||||
Title = list.Count == 1
|
||||
? $"{verb}“{PathHelper.GetFileName(list[0])}”到 {destinationPath}"
|
||||
: $"{verb} {list.Count} 个项目到 {destinationPath}",
|
||||
Sources = list,
|
||||
Destination = destinationPath,
|
||||
Policy = policy
|
||||
};
|
||||
|
||||
_queue.Enqueue(job, instantLane: kind == FileOperationKind.Move && IsSameVolumeMove(list, destinationPath));
|
||||
return job;
|
||||
}
|
||||
|
||||
private static List<string> NormalizePaths(IReadOnlyList<string> paths)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (paths is null) return result;
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) continue;
|
||||
var full = PathHelper.TryGetFullPath(path) ?? path.Trim();
|
||||
if (!result.Contains(full, StringComparer.OrdinalIgnoreCase)) result.Add(full);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 目标路径解析(入队与执行两处必须一致):
|
||||
/// - 目标是已存在的目录 / 末尾带分隔符 / 无扩展名 → 视为"放进该目录";
|
||||
/// - 否则(单个源 + 目标不存在 + 带扩展名)→ 目标即完整目标路径,等价于"复制并改名"。
|
||||
/// </summary>
|
||||
private static List<(string Source, string Target)> ResolveTargets(IReadOnlyList<string> sources, string destination)
|
||||
{
|
||||
var targets = new List<(string Source, string Target)>();
|
||||
if (sources.Count == 0) return targets;
|
||||
|
||||
var treatAsDirectory = sources.Count > 1
|
||||
|| PathHelper.DirectoryExists(destination)
|
||||
|| destination.EndsWith(Path.DirectorySeparatorChar)
|
||||
|| destination.EndsWith(Path.AltDirectorySeparatorChar)
|
||||
|| Path.GetExtension(destination).Length == 0;
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
targets.Add(treatAsDirectory
|
||||
? (source, PathHelper.Combine(destination, PathHelper.GetFileName(source)))
|
||||
: (source, destination));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static bool IsSameVolumeMove(IReadOnlyList<string> sources, string destination)
|
||||
{
|
||||
if (sources.Count == 0) return false;
|
||||
foreach (var (source, target) in ResolveTargets(sources, destination))
|
||||
{
|
||||
if (!PathHelper.SameVolume(source, target)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 作业执行
|
||||
|
||||
private async Task ExecuteJobAsync(JobContext ctx)
|
||||
{
|
||||
var job = ctx.Job;
|
||||
var runner = new JobRunner(this, ctx);
|
||||
|
||||
try
|
||||
{
|
||||
job.State = ctx.PauseGate.IsSet ? JobState.Running : JobState.Paused;
|
||||
runner.Notify();
|
||||
|
||||
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
|
||||
if (!ctx.Cts.IsCancellationRequested)
|
||||
{
|
||||
switch (job.Kind)
|
||||
{
|
||||
case FileOperationKind.Copy:
|
||||
await RunTransferAsync(runner, isMove: false).ConfigureAwait(false);
|
||||
break;
|
||||
case FileOperationKind.Move:
|
||||
await RunTransferAsync(runner, isMove: true).ConfigureAwait(false);
|
||||
break;
|
||||
case FileOperationKind.Delete:
|
||||
case FileOperationKind.Recycle:
|
||||
await RunDeleteAsync(runner).ConfigureAwait(false);
|
||||
break;
|
||||
case FileOperationKind.Rename:
|
||||
await RunRenameAsync(runner).ConfigureAwait(false);
|
||||
break;
|
||||
case FileOperationKind.NewFolder:
|
||||
await RunNewFolderAsync(runner).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 取消是正常流程:已完成的部分保留,不回滚。
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
runner.Warn($"作业异常:{ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
runner.Complete();
|
||||
if (runner.UndoEntry is { } entry) PushUndo(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunTransferAsync(JobRunner runner, bool isMove)
|
||||
{
|
||||
var job = runner.Job;
|
||||
var targets = ResolveTargets(job.Sources, job.Destination ?? string.Empty);
|
||||
var sameVolumeMove = isMove && IsSameVolumeMove(job.Sources, job.Destination ?? string.Empty);
|
||||
|
||||
if (sameVolumeMove)
|
||||
{
|
||||
// 同卷 Move 是瞬时元数据操作,不产生字节流量:
|
||||
// 这里刻意不做字节测量,让进度条按"条目数"走,而不是永远停在 0%。
|
||||
job.TotalBytes = 0;
|
||||
job.TotalItems = targets.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
var (bytes, items) = CopyEngine.Measure(job.Sources, runner.Token, runner.Warn);
|
||||
job.TotalBytes = bytes;
|
||||
job.TotalItems = items;
|
||||
}
|
||||
|
||||
job.IsIndeterminate = false;
|
||||
runner.Notify();
|
||||
|
||||
foreach (var (source, target) in targets)
|
||||
{
|
||||
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (runner.IsCancellationRequested) break;
|
||||
|
||||
runner.SetCurrentItem(source);
|
||||
|
||||
var outcome = isMove
|
||||
? await CopyEngine.MoveEntryAsync(source, target, runner).ConfigureAwait(false)
|
||||
: await CopyEngine.CopyEntryAsync(source, target, runner).ConfigureAwait(false);
|
||||
|
||||
if (outcome == EntryOutcome.Success) runner.CountSucceeded();
|
||||
else if (outcome == EntryOutcome.Cancelled) break;
|
||||
}
|
||||
|
||||
if (isMove && runner.MoveRecords.Count > 0)
|
||||
{
|
||||
runner.SetUndoEntry(new UndoEntry
|
||||
{
|
||||
Description = $"撤销 移动 {job.Sources.Count} 个项目到 {job.Destination}",
|
||||
Kind = FileOperationKind.Move,
|
||||
Moves = [.. runner.MoveRecords]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunDeleteAsync(JobRunner runner)
|
||||
{
|
||||
var job = runner.Job;
|
||||
if (job.Sources.Count == 0) return;
|
||||
|
||||
if (job.PermanentDelete)
|
||||
{
|
||||
var (_, deleteItems) = CopyEngine.Measure(job.Sources, runner.Token, runner.Warn);
|
||||
job.TotalBytes = 0;
|
||||
job.TotalItems = deleteItems;
|
||||
job.IsIndeterminate = false;
|
||||
runner.Notify();
|
||||
|
||||
foreach (var path in job.Sources)
|
||||
{
|
||||
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (runner.IsCancellationRequested) break;
|
||||
|
||||
var outcome = await CopyEngine.DeletePermanentAsync(path, runner, countAsItem: true).ConfigureAwait(false);
|
||||
if (outcome == EntryOutcome.Success) runner.CountSucceeded();
|
||||
else if (outcome == EntryOutcome.Cancelled) break;
|
||||
}
|
||||
|
||||
// 永久删除无法撤销:不产生 UndoEntry(避免 Ctrl+Z 出现"撤销后什么都没发生"的空操作)。
|
||||
return;
|
||||
}
|
||||
|
||||
job.TotalBytes = 0;
|
||||
job.TotalItems = job.Sources.Count;
|
||||
job.IsIndeterminate = true;
|
||||
runner.Notify();
|
||||
|
||||
// 1) 删除前对每个卷的 <卷>:\$Recycle.Bin\<SID>\ 做 $I 快照(撤销定位靠前后差集)。
|
||||
var before = RecycleBinLocator.CaptureState(job.Sources);
|
||||
|
||||
runner.SetCurrentItem(job.Sources.Count == 1 ? job.Sources[0] : $"{job.Sources.Count} 个项目");
|
||||
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (runner.IsCancellationRequested) return;
|
||||
|
||||
// 2) 一次 Shell 调用完成一批(FOF_ALLOWUNDO = 进回收站而不是永久删除)。
|
||||
var (success, aborted, code) = await RecycleBinLocator.DeleteToRecycleBinAsync(job.Sources).ConfigureAwait(false);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
var reason = aborted ? "操作被 Shell 中止。" : RecycleBinLocator.DescribeResult(code);
|
||||
runner.Warn($"删除到回收站失败:{reason}");
|
||||
runner.CountFailed(job.Sources.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
var deletedCount = job.Sources.Count(p => !PathHelper.Exists(p));
|
||||
job.CompletedItems = deletedCount;
|
||||
runner.CountSucceeded(Math.Max(deletedCount, 0));
|
||||
|
||||
foreach (var path in job.Sources.Where(PathHelper.Exists))
|
||||
runner.Warn($"Shell 报告成功但文件仍然存在:{path}");
|
||||
|
||||
// 3) 差集定位回收站内新增的 $I/$R,填进 UndoEntry.Deleted(解析失败只警告,绝不崩溃)。
|
||||
var (items, diagnostics) = RecycleBinLocator.ResolveDeletedItems(job.Sources, before);
|
||||
foreach (var diagnostic in diagnostics) runner.Warn(diagnostic);
|
||||
|
||||
if (items.Count > 0 && items.All(i => string.IsNullOrEmpty(i.RecyclePath)))
|
||||
{
|
||||
runner.Warn("回收站不可用(Shell 无法在 <卷>:\\$Recycle.Bin 下建立 $I/$R 记录),本次删除实际为永久删除,无法撤销。");
|
||||
}
|
||||
|
||||
if (items.Count > 0)
|
||||
{
|
||||
runner.SetUndoEntry(new UndoEntry
|
||||
{
|
||||
Description = $"撤销 删除 {deletedCount} 个项目",
|
||||
Kind = FileOperationKind.Recycle,
|
||||
Moves = [],
|
||||
Deleted = items
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunRenameAsync(JobRunner runner)
|
||||
{
|
||||
var job = runner.Job;
|
||||
job.TotalBytes = 0;
|
||||
job.TotalItems = 1;
|
||||
job.IsIndeterminate = true;
|
||||
runner.Notify();
|
||||
|
||||
var source = job.Sources.FirstOrDefault();
|
||||
if (source is null)
|
||||
{
|
||||
runner.Warn("没有指定要重命名的路径。");
|
||||
runner.CountFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
var newName = job.NewName ?? string.Empty;
|
||||
if (!PathHelper.TryValidateFileName(newName, out var validationError))
|
||||
{
|
||||
runner.Warn(validationError!);
|
||||
runner.CountFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
var target = PathHelper.Combine(PathHelper.GetDirectoryName(source), newName);
|
||||
|
||||
if (string.Equals(source.TrimEnd('\\'), target.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 名称没变化:直接算成功,不要动文件。
|
||||
job.CompletedItems = 1;
|
||||
runner.CountSucceeded();
|
||||
return;
|
||||
}
|
||||
|
||||
if (PathHelper.Exists(target))
|
||||
{
|
||||
runner.Warn($"目标名称已存在,重命名不会覆盖:{target}");
|
||||
runner.CountFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (runner.IsCancellationRequested) return;
|
||||
|
||||
var outcome = await CopyEngine.MoveDirectAsync(source, target, runner).ConfigureAwait(false);
|
||||
if (outcome == EntryOutcome.Success)
|
||||
{
|
||||
job.CompletedItems = 1;
|
||||
runner.CountSucceeded();
|
||||
runner.SetUndoEntry(new UndoEntry
|
||||
{
|
||||
Description = $"撤销 重命名“{newName}”",
|
||||
Kind = FileOperationKind.Rename,
|
||||
Moves = [.. runner.MoveRecords]
|
||||
});
|
||||
}
|
||||
else if (outcome != EntryOutcome.Cancelled)
|
||||
{
|
||||
runner.CountFailed();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunNewFolderAsync(JobRunner runner)
|
||||
{
|
||||
var job = runner.Job;
|
||||
job.TotalBytes = 0;
|
||||
job.TotalItems = 1;
|
||||
job.IsIndeterminate = true;
|
||||
runner.Notify();
|
||||
|
||||
var parent = job.Destination ?? job.Sources.FirstOrDefault();
|
||||
if (parent is null)
|
||||
{
|
||||
runner.Warn("没有指定父目录。");
|
||||
runner.CountFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
var name = job.NewName ?? string.Empty;
|
||||
if (!PathHelper.TryValidateFileName(name, out var validationError))
|
||||
{
|
||||
runner.Warn(validationError!);
|
||||
runner.CountFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||
if (runner.IsCancellationRequested) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(PathHelper.ToExtended(parent));
|
||||
|
||||
// 重名时自动避让:"新建文件夹 (2)"。
|
||||
var target = PathHelper.MakeUniquePath(PathHelper.Combine(parent, name));
|
||||
Directory.CreateDirectory(PathHelper.ToExtended(target));
|
||||
job.CurrentItem = target;
|
||||
job.CompletedItems = 1;
|
||||
runner.CountSucceeded();
|
||||
runner.Warn($"已创建:{target}");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
runner.Warn($"新建文件夹失败:{ex.Message}");
|
||||
runner.CountFailed();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 撤销
|
||||
|
||||
public bool CanUndo
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_undoGate) return _undoStack.Count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public string? UndoDescription
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_undoGate) return _undoStack.Count > 0 ? _undoStack.Peek().Description : null;
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler? UndoStackChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 一步撤销栈顶作业:Move/Rename 逐项搬回原位置,Recycle 把回收站里的 $R 搬回原始路径。
|
||||
/// 所有 IO 都在线程池上执行(Task.Run + 异步 IO),UI await 即可,绝不会阻塞 UI 线程。
|
||||
/// </summary>
|
||||
public Task<OperationResult> UndoAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.Run(() => UndoCoreAsync(cancellationToken), CancellationToken.None);
|
||||
|
||||
private async Task<OperationResult> UndoCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
UndoEntry? entry;
|
||||
lock (_undoGate)
|
||||
{
|
||||
if (_undoStack.Count == 0) return new OperationResult(false, 0, 0, 0, "没有可撤销的操作。");
|
||||
entry = _undoStack.Pop();
|
||||
}
|
||||
|
||||
UndoStackChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
var sink = new UndoSink(cancellationToken);
|
||||
var succeeded = 0;
|
||||
var failed = 0;
|
||||
|
||||
// 1) Move / Rename:把 From(当前位置)搬回 To(原位置)。同卷时是瞬时 File.Move。
|
||||
foreach (var (from, to) in entry.Moves)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
if (!PathHelper.Exists(from))
|
||||
{
|
||||
sink.Warn($"撤销失败,找不到待搬回的项目:{from}");
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
PathHelper.EnsureParentDirectory(to);
|
||||
|
||||
// 撤销时遇到冲突按 KeepBoth:绝不覆盖用户现有数据。
|
||||
var target = PathHelper.Exists(to) ? PathHelper.MakeUniquePath(to) : to;
|
||||
|
||||
var outcome = await CopyEngine.MoveDirectAsync(from, target, sink).ConfigureAwait(false);
|
||||
if (outcome == EntryOutcome.Success) succeeded++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
// 2) Recycle:把回收站里的 $R 数据搬回原始路径(目标父目录不存在时先创建)。
|
||||
foreach (var (recyclePath, originalPath) in entry.Deleted)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
if (string.IsNullOrEmpty(recyclePath) || !PathHelper.Exists(recyclePath))
|
||||
{
|
||||
sink.Warn($"无法还原(回收站条目缺失或 $I/$R 解析失败):{originalPath}");
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
PathHelper.EnsureParentDirectory(originalPath);
|
||||
var target = PathHelper.Exists(originalPath) ? PathHelper.MakeUniquePath(originalPath) : originalPath;
|
||||
|
||||
var outcome = await CopyEngine.MoveDirectAsync(recyclePath, target, sink).ConfigureAwait(false);
|
||||
if (outcome == EntryOutcome.Success)
|
||||
{
|
||||
succeeded++;
|
||||
RemoveIndexFile(recyclePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
var errors = sink.Errors.Count > 0 ? string.Join(Environment.NewLine, sink.Errors) : null;
|
||||
return new OperationResult(failed == 0 && succeeded > 0, succeeded, failed, 0, errors);
|
||||
}
|
||||
|
||||
/// <summary>还原成功后顺手删掉对应的 $I 索引,避免回收站里留下指向不存在数据的死条目。</summary>
|
||||
private static void RemoveIndexFile(string recycleDataPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var indexPath = RecycleBinLocator.GetIndexPathFromDataPath(recycleDataPath);
|
||||
if (indexPath is not null && PathHelper.FileExists(indexPath))
|
||||
{
|
||||
PathHelper.ClearReadOnly(indexPath);
|
||||
File.Delete(PathHelper.ToExtended(indexPath));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 元数据清理失败不影响还原结果。
|
||||
}
|
||||
}
|
||||
|
||||
private void PushUndo(UndoEntry entry)
|
||||
{
|
||||
if (entry.Moves.Count == 0 && entry.Deleted.Count == 0) return;
|
||||
|
||||
lock (_undoGate)
|
||||
{
|
||||
_undoStack.Push(entry);
|
||||
|
||||
if (_undoStack.Count > MaxUndoEntries)
|
||||
{
|
||||
// Stack 的枚举顺序是"栈顶在前",取前 N 条即保留最新的 N 条。
|
||||
var kept = _undoStack.Take(MaxUndoEntries).Reverse().ToArray();
|
||||
_undoStack.Clear();
|
||||
foreach (var item in kept) _undoStack.Push(item);
|
||||
}
|
||||
}
|
||||
|
||||
UndoStackChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 测量
|
||||
|
||||
public Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = paths is null || paths.Count == 0 ? [] : NormalizePaths(paths);
|
||||
return Task.Run(() => CopyEngine.Measure(list, cancellationToken), CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_queue.Dispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 撤销用的空实现 sink
|
||||
|
||||
private sealed class UndoSink : IJobSink
|
||||
{
|
||||
public UndoSink(CancellationToken token) => Token = token;
|
||||
|
||||
public List<string> Errors { get; } = [];
|
||||
|
||||
public CancellationToken Token { get; }
|
||||
|
||||
public bool IsCancellationRequested => Token.IsCancellationRequested;
|
||||
|
||||
public Task WaitIfPausedAsync() => Task.CompletedTask;
|
||||
|
||||
public void AddBytes(long delta) { }
|
||||
|
||||
public void AddCompletedItems(int delta) { }
|
||||
|
||||
public void SetCurrentItem(string path) { }
|
||||
|
||||
public void Warn(string message) => Errors.Add(message);
|
||||
|
||||
public void AddFailed(string path, string reason) => Errors.Add($"{path}:{reason}");
|
||||
|
||||
public void AddSkipped(int delta = 1) { }
|
||||
|
||||
public void RecordMoveForUndo(string newPath, string originalPath) { }
|
||||
|
||||
public Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info) => Task.FromResult(ConflictResolution.KeepBoth);
|
||||
|
||||
public void RequestCancel() { }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 单个作业的运行器
|
||||
|
||||
/// <summary>
|
||||
/// 单个作业的执行上下文:负责限频进度上报、统计成功/失败/跳过、收集撤销记录与诊断信息。
|
||||
/// </summary>
|
||||
private sealed class JobRunner : IJobSink
|
||||
{
|
||||
private readonly FileOperationService _owner;
|
||||
private readonly JobContext _ctx;
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
private readonly List<string> _diagnostics = [];
|
||||
private readonly List<(string From, string To)> _moveRecords = [];
|
||||
private long _pendingBytes;
|
||||
private int _pendingItems;
|
||||
private string? _pendingCurrentItem;
|
||||
private long _lastFlushMs;
|
||||
private long _lastBytesFlushMs;
|
||||
private long _lastRaiseAllMs;
|
||||
private double _speed;
|
||||
private int _succeeded;
|
||||
private int _failed;
|
||||
private int _skipped;
|
||||
private ConflictResolution? _applyToAll;
|
||||
|
||||
public JobRunner(FileOperationService owner, JobContext ctx)
|
||||
{
|
||||
_owner = owner;
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
public FileOperationJob Job => _ctx.Job;
|
||||
|
||||
public CancellationToken Token => _ctx.Cts.Token;
|
||||
|
||||
public bool IsCancellationRequested => _ctx.Cts.IsCancellationRequested;
|
||||
|
||||
public List<(string From, string To)> MoveRecords => _moveRecords;
|
||||
|
||||
public UndoEntry? UndoEntry { get; private set; }
|
||||
|
||||
public void SetUndoEntry(UndoEntry entry) => UndoEntry = entry;
|
||||
|
||||
/// <summary>立即刷新一次进度并通知 UI(作业开始、阶段切换、结束等关键时刻)。</summary>
|
||||
public void Notify() => FlushProgress(force: true);
|
||||
|
||||
public async Task WaitIfPausedAsync()
|
||||
{
|
||||
var gate = _ctx.PauseGate;
|
||||
while (!gate.IsSet)
|
||||
{
|
||||
if (IsCancellationRequested) return;
|
||||
await gate.WaitAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddBytes(long delta)
|
||||
{
|
||||
if (delta == 0) return;
|
||||
Interlocked.Add(ref _pendingBytes, delta);
|
||||
FlushIfDue();
|
||||
}
|
||||
|
||||
public void AddCompletedItems(int delta)
|
||||
{
|
||||
if (delta == 0) return;
|
||||
Interlocked.Add(ref _pendingItems, delta);
|
||||
FlushIfDue();
|
||||
}
|
||||
|
||||
public void SetCurrentItem(string path)
|
||||
{
|
||||
Interlocked.Exchange(ref _pendingCurrentItem, path);
|
||||
FlushIfDue();
|
||||
}
|
||||
|
||||
public void AddSkipped(int delta = 1)
|
||||
{
|
||||
_skipped += delta;
|
||||
AddCompletedItems(delta);
|
||||
}
|
||||
|
||||
public void AddFailed(string path, string reason)
|
||||
{
|
||||
_failed++;
|
||||
Warn($"{path}:{reason}");
|
||||
}
|
||||
|
||||
public void Warn(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message)) return;
|
||||
_diagnostics.Add(message);
|
||||
}
|
||||
|
||||
public void CountSucceeded(int delta = 1) => _succeeded += delta;
|
||||
|
||||
public void CountFailed(int delta = 1) => _failed += delta;
|
||||
|
||||
public void RecordMoveForUndo(string newPath, string originalPath) => _moveRecords.Add((newPath, originalPath));
|
||||
|
||||
public void RequestCancel()
|
||||
{
|
||||
Warn("已按用户选择取消后续操作。");
|
||||
_ctx.Cts.Cancel();
|
||||
_ctx.PauseGate.Set();
|
||||
}
|
||||
|
||||
public async Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info)
|
||||
{
|
||||
// "为后续所有冲突执行相同操作":一次勾选,后续冲突不再打扰 UI。
|
||||
if (_applyToAll is { } applied) return applied;
|
||||
|
||||
var resolver = _owner.ConflictResolver;
|
||||
if (Job.Policy == ConflictPolicy.Ask && resolver is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 回调是 await 的:期间作业状态保持 Running(或 Paused),当前项挂起,
|
||||
// 但 UI 线程完全自由(回调由 UI 自己 marshal 回 UI 线程弹对话框)。
|
||||
var resolution = await resolver(info).ConfigureAwait(false);
|
||||
if (info.ApplyToAll) _applyToAll = resolution;
|
||||
return resolution;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Warn($"冲突回调异常,按“保留两者”处理:{ex.Message}");
|
||||
return ConflictResolution.KeepBoth;
|
||||
}
|
||||
}
|
||||
|
||||
return Job.Policy switch
|
||||
{
|
||||
ConflictPolicy.Replace or ConflictPolicy.Merge => ConflictResolution.Replace,
|
||||
ConflictPolicy.Skip => ConflictResolution.Skip,
|
||||
_ => ConflictResolution.KeepBoth // Ask 但没有 UI 回调 → 保留两者
|
||||
};
|
||||
}
|
||||
|
||||
private void FlushIfDue()
|
||||
{
|
||||
if (_clock.ElapsedMilliseconds - _lastFlushMs >= ProgressFlushIntervalMs) FlushProgress(force: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 限频进度刷新:
|
||||
/// - 数值属性(字节 / 条目 / 当前项 / 速度)每 50ms 写一次,各自只触发一个 PropertyChanged;
|
||||
/// - 计算属性(Progress / Eta / CanPause…)每 250ms 通过一次 RaiseAll 刷新。
|
||||
/// 于是 2000 个文件的复制只产生约 20 次/秒的 UI 通知,而不是"每个文件一次"的事件风暴。
|
||||
/// </summary>
|
||||
private void FlushProgress(bool force)
|
||||
{
|
||||
var now = _clock.ElapsedMilliseconds;
|
||||
var bytes = Interlocked.Exchange(ref _pendingBytes, 0);
|
||||
var items = Interlocked.Exchange(ref _pendingItems, 0);
|
||||
var current = Interlocked.Exchange(ref _pendingCurrentItem, null);
|
||||
|
||||
if (bytes != 0)
|
||||
{
|
||||
Job.CompletedBytes += bytes;
|
||||
|
||||
var elapsed = Math.Max(1, now - _lastBytesFlushMs);
|
||||
var instant = bytes * 1000.0 / elapsed;
|
||||
_speed = _speed <= 1 ? instant : (_speed * 0.7) + (instant * 0.3);
|
||||
Job.BytesPerSecond = _speed;
|
||||
_lastBytesFlushMs = now;
|
||||
}
|
||||
|
||||
if (items != 0) Job.CompletedItems += items;
|
||||
if (current is not null) Job.CurrentItem = current;
|
||||
|
||||
_lastFlushMs = now;
|
||||
|
||||
if (force || now - _lastRaiseAllMs >= JobsChangedThrottleMs)
|
||||
{
|
||||
_lastRaiseAllMs = now;
|
||||
// RaiseAll 让绑定 Progress / Eta 的 UI 也能刷新(普通 Set 只通知单个属性)。
|
||||
Job.RaiseAll();
|
||||
_owner.NotifyJobsChangedThrottled();
|
||||
}
|
||||
}
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
FlushProgress(force: true);
|
||||
|
||||
var job = Job;
|
||||
var cancelled = IsCancellationRequested;
|
||||
|
||||
if (cancelled)
|
||||
{
|
||||
var bytes = job.TotalBytes > 0
|
||||
? $",{FormatBytes(job.CompletedBytes)}/{FormatBytes(job.TotalBytes)}"
|
||||
: string.Empty;
|
||||
_diagnostics.Insert(0, $"已取消,已完成 {job.CompletedItems}/{job.TotalItems} 项{bytes}。");
|
||||
job.State = JobState.Cancelled;
|
||||
}
|
||||
else if (_failed > 0)
|
||||
{
|
||||
job.State = _succeeded > 0 ? JobState.CompletedWithErrors : JobState.Failed;
|
||||
}
|
||||
else
|
||||
{
|
||||
job.State = JobState.Completed;
|
||||
}
|
||||
|
||||
if (_skipped > 0) _diagnostics.Add($"已跳过 {_skipped} 个项目。");
|
||||
job.Error = BuildDiagnostics();
|
||||
|
||||
Job.RaiseAll();
|
||||
_owner._queue.Raise();
|
||||
}
|
||||
|
||||
private string? BuildDiagnostics()
|
||||
{
|
||||
if (_diagnostics.Count == 0) return null;
|
||||
|
||||
var builder = new StringBuilder();
|
||||
var included = 0;
|
||||
foreach (var line in _diagnostics)
|
||||
{
|
||||
if (builder.Length + line.Length + 1 > MaxErrorLength)
|
||||
{
|
||||
builder.Append(Environment.NewLine).Append($"…(其余 {_diagnostics.Count - included} 条已省略)");
|
||||
break;
|
||||
}
|
||||
|
||||
if (builder.Length > 0) builder.Append(Environment.NewLine);
|
||||
builder.Append(line);
|
||||
included++;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] units = ["B", "KB", "MB", "GB", "TB"];
|
||||
double value = bytes;
|
||||
var unit = 0;
|
||||
while (value >= 1024 && unit < units.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
return $"{value:0.##}{units[unit]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
public enum FileOperationKind
|
||||
{
|
||||
Copy,
|
||||
Move,
|
||||
Delete,
|
||||
Recycle,
|
||||
Rename,
|
||||
NewFolder
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
{
|
||||
Queued,
|
||||
Running,
|
||||
Paused,
|
||||
Completed,
|
||||
CompletedWithErrors,
|
||||
Cancelled,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum ConflictPolicy
|
||||
{
|
||||
/// <summary>交给 UI 决定(ConflictResolver)。</summary>
|
||||
Ask,
|
||||
Replace,
|
||||
Skip,
|
||||
KeepBoth,
|
||||
Merge
|
||||
}
|
||||
|
||||
public enum ConflictResolution
|
||||
{
|
||||
Replace,
|
||||
Skip,
|
||||
KeepBoth,
|
||||
Cancel
|
||||
}
|
||||
|
||||
public sealed class ConflictInfo
|
||||
{
|
||||
public required string SourcePath { get; init; }
|
||||
public required string DestinationPath { get; init; }
|
||||
public bool SourceIsDirectory { get; init; }
|
||||
public long SourceSize { get; init; }
|
||||
public long DestinationSize { get; init; }
|
||||
public DateTime SourceModifiedUtc { get; init; }
|
||||
public DateTime DestinationModifiedUtc { get; init; }
|
||||
/// <summary>true 表示用户勾选了"为后续所有冲突执行相同操作"。</summary>
|
||||
public bool ApplyToAll { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>队列里的一个作业:可暂停/继续/取消,进度实时上报(含速度与剩余时间)。</summary>
|
||||
public sealed class FileOperationJob : INotifyPropertyChanged
|
||||
{
|
||||
private JobState _state = JobState.Queued;
|
||||
private int _completedItems;
|
||||
private long _completedBytes;
|
||||
private string? _currentItem;
|
||||
private string? _error;
|
||||
private double _bytesPerSecond;
|
||||
private bool _isIndeterminate;
|
||||
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
public required FileOperationKind Kind { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required IReadOnlyList<string> Sources { get; init; }
|
||||
public string? Destination { get; init; }
|
||||
public ConflictPolicy Policy { get; init; } = ConflictPolicy.Ask;
|
||||
public bool PermanentDelete { get; init; }
|
||||
public string? NewName { get; init; }
|
||||
public DateTime StartedUtc { get; } = DateTime.UtcNow;
|
||||
|
||||
public JobState State
|
||||
{
|
||||
get => _state;
|
||||
set => Set(ref _state, value);
|
||||
}
|
||||
|
||||
public int TotalItems { get; set; }
|
||||
public int CompletedItems { get => _completedItems; set => Set(ref _completedItems, value); }
|
||||
public long TotalBytes { get; set; }
|
||||
public long CompletedBytes { get => _completedBytes; set => Set(ref _completedBytes, value); }
|
||||
public double BytesPerSecond { get => _bytesPerSecond; set => Set(ref _bytesPerSecond, value); }
|
||||
public string? CurrentItem { get => _currentItem; set => Set(ref _currentItem, value); }
|
||||
public string? Error { get => _error; set => Set(ref _error, value); }
|
||||
|
||||
/// <summary>大小为 0 的作业(纯重命名等)显示旋转指示器。</summary>
|
||||
public bool IsIndeterminate { get => _isIndeterminate; set => Set(ref _isIndeterminate, value); }
|
||||
|
||||
public double Progress => TotalBytes > 0
|
||||
? Math.Clamp((double)CompletedBytes / TotalBytes, 0, 1)
|
||||
: (TotalItems > 0 ? Math.Clamp((double)CompletedItems / TotalItems, 0, 1) : 0);
|
||||
|
||||
public TimeSpan? Eta => BytesPerSecond > 1 && TotalBytes > CompletedBytes
|
||||
? TimeSpan.FromSeconds((TotalBytes - CompletedBytes) / BytesPerSecond)
|
||||
: null;
|
||||
|
||||
public bool CanPause => State is JobState.Running or JobState.Paused;
|
||||
public bool CanCancel => State is JobState.Queued or JobState.Running or JobState.Paused;
|
||||
public bool IsFinished => State is JobState.Completed or JobState.CompletedWithErrors or JobState.Cancelled or JobState.Failed;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
internal void RaiseAll()
|
||||
{
|
||||
foreach (var name in new[] { nameof(State), nameof(CompletedItems), nameof(CompletedBytes), nameof(BytesPerSecond),
|
||||
nameof(CurrentItem), nameof(Error), nameof(Progress), nameof(Eta),
|
||||
nameof(CanPause), nameof(CanCancel), nameof(IsFinished), nameof(IsIndeterminate) })
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
private void Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UndoEntry
|
||||
{
|
||||
public required string Description { get; init; }
|
||||
public required FileOperationKind Kind { get; init; }
|
||||
/// <summary>(原路径, 现路径) 对;撤销即反向搬运。</summary>
|
||||
public required List<(string From, string To)> Moves { get; init; }
|
||||
/// <summary>删除操作记录:回收站里的 $R 文件路径 → 原始路径。</summary>
|
||||
public List<(string RecyclePath, string OriginalPath)> Deleted { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record OperationResult(bool Success, int Succeeded, int Failed, int Skipped, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// 文件操作引擎:所有操作进入统一队列串行/并行执行,UI 永不阻塞。
|
||||
/// 支持暂停、继续、取消、冲突策略、错误重试,以及一步撤销(Ctrl+Z)。
|
||||
/// </summary>
|
||||
public interface IFileOperationService
|
||||
{
|
||||
IReadOnlyList<FileOperationJob> Jobs { get; }
|
||||
event EventHandler? JobsChanged;
|
||||
|
||||
/// <summary>UI 设置此回调以弹出冲突对话框;未设置时按 KeepBoth 处理。</summary>
|
||||
Func<ConflictInfo, Task<ConflictResolution>>? ConflictResolver { get; set; }
|
||||
|
||||
FileOperationJob EnqueueCopy(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask);
|
||||
FileOperationJob EnqueueMove(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask);
|
||||
FileOperationJob EnqueueDelete(IReadOnlyList<string> paths, bool permanent = false);
|
||||
FileOperationJob EnqueueRename(string path, string newName);
|
||||
FileOperationJob EnqueueNewFolder(string parentDirectory, string name);
|
||||
|
||||
void Pause(Guid jobId);
|
||||
void Resume(Guid jobId);
|
||||
void Cancel(Guid jobId);
|
||||
void ClearFinished();
|
||||
|
||||
bool CanUndo { get; }
|
||||
string? UndoDescription { get; }
|
||||
event EventHandler? UndoStackChanged;
|
||||
Task<OperationResult> UndoAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>计算源集合的总大小与条目数(后台执行,用于进度条与冲突提示)。</summary>
|
||||
Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>
|
||||
/// 可异步等待的自动/手动复位事件:用于"暂停"语义。
|
||||
/// 关键点:<see cref="TaskCompletionSource"/> 必须带 RunContinuationsAsynchronously,
|
||||
/// 否则 <see cref="Set"/> 会在调用线程(通常是 UI 线程)上同步执行拷贝循环的续体,
|
||||
/// 从而把磁盘 IO 拖回 UI 线程。
|
||||
/// </summary>
|
||||
internal sealed class AsyncManualResetEvent
|
||||
{
|
||||
private TaskCompletionSource _tcs;
|
||||
|
||||
public AsyncManualResetEvent(bool initialState = true) => _tcs = Create(initialState);
|
||||
|
||||
public bool IsSet => _tcs.Task.IsCompleted;
|
||||
|
||||
public Task WaitAsync() => _tcs.Task;
|
||||
|
||||
public void Set() => _tcs.TrySetResult();
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = _tcs;
|
||||
if (!current.Task.IsCompleted) return;
|
||||
var fresh = Create(false);
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _tcs, fresh, current), current)) return;
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskCompletionSource Create(bool completed)
|
||||
{
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (completed) tcs.SetResult();
|
||||
return tcs;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>引擎内部对一个作业的控制块:取消令牌 + 暂停闸门 + 调度车道标记。</summary>
|
||||
internal sealed class JobContext
|
||||
{
|
||||
public JobContext(FileOperationJob job) => Job = job;
|
||||
|
||||
public FileOperationJob Job { get; }
|
||||
|
||||
public CancellationTokenSource Cts { get; } = new();
|
||||
|
||||
/// <summary>初始为 Set(未暂停)。Reset 即暂停,Set 即继续。</summary>
|
||||
public AsyncManualResetEvent PauseGate { get; } = new(initialState: true);
|
||||
|
||||
/// <summary>
|
||||
/// true = 走"瞬时车道":同卷 Move / 重命名 / 新建文件夹这类不搬字节的操作,
|
||||
/// 可以和其他作业并行,不必排队等大拷贝。
|
||||
/// </summary>
|
||||
public bool InstantLane { get; set; }
|
||||
|
||||
public bool Started { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 作业队列:后台 worker 严格按入队顺序调度。
|
||||
/// 默认串行(避免多任务同时读盘造成磁盘抖动、拖慢整体吞吐),
|
||||
/// 但"瞬时操作"(同卷 Move / 重命名 / 新建文件夹)允许并行,因为它们只做元数据操作。
|
||||
/// </summary>
|
||||
internal sealed class JobQueue : IDisposable
|
||||
{
|
||||
/// <summary>瞬时车道最大并行度,防止一次排入上千个瞬时作业时线程爆炸。</summary>
|
||||
private const int MaxInstantParallelism = 4;
|
||||
|
||||
private readonly Func<JobContext, Task> _executor;
|
||||
private readonly ObservableCollection<FileOperationJob> _jobs = [];
|
||||
private readonly List<JobContext> _pending = [];
|
||||
private readonly Dictionary<Guid, JobContext> _contexts = [];
|
||||
private readonly HashSet<Task> _instantTasks = [];
|
||||
private readonly SemaphoreSlim _signal = new(0);
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private readonly object _gate = new();
|
||||
private Task? _dispatcher;
|
||||
private bool _disposed;
|
||||
|
||||
public JobQueue(Func<JobContext, Task> executor) => _executor = executor;
|
||||
|
||||
public ObservableCollection<FileOperationJob> Jobs => _jobs;
|
||||
|
||||
public event EventHandler? JobsChanged;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_dispatcher ??= Task.Run(DispatcherLoopAsync);
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue(FileOperationJob job, bool instantLane)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
var ctx = new JobContext(job) { InstantLane = instantLane };
|
||||
_contexts[job.Id] = ctx;
|
||||
_pending.Add(ctx);
|
||||
_jobs.Add(job);
|
||||
}
|
||||
|
||||
_signal.Release();
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void Pause(Guid jobId)
|
||||
{
|
||||
JobContext? ctx;
|
||||
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||
if (ctx is null) return;
|
||||
|
||||
// 只有已经在跑的作业才能暂停(Queued 状态的作业由模型定义为 CanPause=false)。
|
||||
if (!ctx.Job.CanPause) return;
|
||||
|
||||
ctx.PauseGate.Reset();
|
||||
if (ctx.Job.State == JobState.Running) ctx.Job.State = JobState.Paused;
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void Resume(Guid jobId)
|
||||
{
|
||||
JobContext? ctx;
|
||||
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||
if (ctx is null || ctx.Job.IsFinished) return;
|
||||
|
||||
ctx.PauseGate.Set();
|
||||
if (ctx.Job.State == JobState.Paused) ctx.Job.State = JobState.Running;
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void Cancel(Guid jobId)
|
||||
{
|
||||
JobContext? ctx;
|
||||
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||
if (ctx is null || ctx.Job.IsFinished) return;
|
||||
|
||||
ctx.Cts.Cancel();
|
||||
|
||||
// 让处于暂停中的拷贝循环立刻被唤醒,从而在同一粒度内观察到取消。
|
||||
ctx.PauseGate.Set();
|
||||
|
||||
if (ctx.Job.State == JobState.Queued)
|
||||
{
|
||||
// 还没开始跑:直接落终态,调度循环只挑 Queued 的作业,因此会自然跳过。
|
||||
ctx.Job.State = JobState.Cancelled;
|
||||
ctx.Job.Error = AppendLine(ctx.Job.Error, "已取消(尚未开始)。");
|
||||
}
|
||||
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void ClearFinished()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
for (var i = _jobs.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!_jobs[i].IsFinished) continue;
|
||||
_contexts.Remove(_jobs[i].Id);
|
||||
_jobs.RemoveAt(i);
|
||||
}
|
||||
|
||||
_pending.RemoveAll(c => c.Job.IsFinished);
|
||||
}
|
||||
|
||||
Raise();
|
||||
}
|
||||
|
||||
public void Raise() => JobsChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
internal static string AppendLine(string? existing, string line)
|
||||
=> string.IsNullOrEmpty(existing) ? line : existing + Environment.NewLine + line;
|
||||
|
||||
private async Task DispatcherLoopAsync()
|
||||
{
|
||||
var token = _shutdown.Token;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _signal.WaitAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
JobContext? ctx;
|
||||
lock (_gate)
|
||||
{
|
||||
ctx = _pending.FirstOrDefault(c => c.Job.State == JobState.Queued);
|
||||
if (ctx is not null && ctx.InstantLane && _instantTasks.Count >= MaxInstantParallelism)
|
||||
ctx = null; // 车道满了:本轮不挑,等下一轮(等价于退化成串行)
|
||||
if (ctx is not null) ctx.Started = true;
|
||||
}
|
||||
|
||||
if (ctx is null) break;
|
||||
|
||||
if (ctx.InstantLane)
|
||||
{
|
||||
var task = Task.Run(() => RunSafelyAsync(ctx), CancellationToken.None);
|
||||
lock (_gate) _instantTasks.Add(task);
|
||||
_ = task.ContinueWith(
|
||||
t =>
|
||||
{
|
||||
lock (_gate) _instantTasks.Remove(t);
|
||||
_signal.Release(); // 唤醒调度循环,检查是否还有排队的作业
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
else
|
||||
{
|
||||
await RunSafelyAsync(ctx).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lock (_gate) _pending.Remove(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunSafelyAsync(JobContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _executor(ctx).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (!ctx.Job.IsFinished) ctx.Job.State = JobState.Cancelled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ctx.Job.Error = AppendLine(ctx.Job.Error, ex.Message);
|
||||
if (!ctx.Job.IsFinished) ctx.Job.State = JobState.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Raise();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
_shutdown.Cancel();
|
||||
try { _signal.Release(); } catch (Exception) { /* 忽略 */ }
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var ctx in _contexts.Values)
|
||||
{
|
||||
try { ctx.Cts.Cancel(); } catch (Exception) { /* 忽略 */ }
|
||||
ctx.Cts.Dispose();
|
||||
}
|
||||
|
||||
_contexts.Clear();
|
||||
}
|
||||
|
||||
_signal.Dispose();
|
||||
_shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>
|
||||
/// 路径与文件系统元数据工具。
|
||||
///
|
||||
/// 设计约定(整个 Operations 引擎统一遵守):
|
||||
/// 1. 引擎内部、作业描述、UndoEntry 里保存的都是"普通路径"(不带 \\?\ 前缀),
|
||||
/// 只有真正触达 BCL / Win32 IO 的那一刻才通过 <see cref="ToExtended"/> 转换,
|
||||
/// 避免 \\?\ 前缀泄漏到 UI 显示、$I 解析、Shell API 调用里(Shell API 不接受前缀)。
|
||||
/// 2. 所有拼接都用 <see cref="Combine"/>(手工拼分隔符),不用 Path.Combine 后直接丢给 API,
|
||||
/// 以保证超长路径(>260)在所有环节都能正确走到 \\?\ 分支。
|
||||
/// 3. 所有 IO 调用一律走 <see cref="ToExtended"/>,NET8 虽然自身也会兜底长路径,
|
||||
/// 但统一处理后行为可预期(尤其是 UNC:\\server\share → \\?\UNC\server\share)。
|
||||
/// </summary>
|
||||
internal static partial class PathHelper
|
||||
{
|
||||
internal const string ExtendedPrefix = @"\\?\";
|
||||
internal const string ExtendedUncPrefix = @"\\?\UNC\";
|
||||
|
||||
[GeneratedRegex(@"^(.*) \((\d+)\)$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex CopySuffixRegex();
|
||||
|
||||
/// <summary>安全取全路径;非法路径(空、含非法字符、超长到无法规范化)返回 null 而不抛。</summary>
|
||||
internal static string? TryGetFullPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
try
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>转成 Win32 长路径形式(\\?\ 或 \\?\UNC\)。已是前缀形式则原样返回。</summary>
|
||||
internal static string ToExtended(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
|
||||
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) ||
|
||||
path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||
return path;
|
||||
|
||||
string full;
|
||||
try
|
||||
{
|
||||
full = Path.GetFullPath(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 无法规范化:原样返回,让后续 API 抛出可读异常,由重试/失败统计兜住。
|
||||
return path;
|
||||
}
|
||||
|
||||
if (full.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) ||
|
||||
full.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||
return full;
|
||||
|
||||
// UNC:\\server\share\x → \\?\UNC\server\share\x
|
||||
if (full.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
return ExtendedUncPrefix + full[2..];
|
||||
|
||||
return ExtendedPrefix + full;
|
||||
}
|
||||
|
||||
/// <summary>去掉长路径前缀,得到可显示/可交给 Shell API 的普通路径。</summary>
|
||||
internal static string StripExtended(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal))
|
||||
return @"\\" + path[ExtendedUncPrefix.Length..];
|
||||
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||
return path[ExtendedPrefix.Length..];
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>手工拼接子路径(不依赖 Path.Combine 的根路径语义)。</summary>
|
||||
internal static string Combine(string directory, string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directory)) return name;
|
||||
var last = directory[^1];
|
||||
return last is '\\' or '/' ? directory + name : directory + Path.DirectorySeparatorChar + name;
|
||||
}
|
||||
|
||||
/// <summary>取最后一段名字(同时兼容带/不带 \\?\ 前缀)。</summary>
|
||||
internal static string GetFileName(string path)
|
||||
{
|
||||
var p = StripExtended(path);
|
||||
if (string.IsNullOrEmpty(p)) return p;
|
||||
var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
if (trimmed.Length == 0) return p; // 卷根,如 "E:\"
|
||||
var idx = trimmed.LastIndexOfAny(['\\', '/']);
|
||||
return idx < 0 ? trimmed : trimmed[(idx + 1)..];
|
||||
}
|
||||
|
||||
/// <summary>取父目录;已是卷根时返回卷根本身(不返回 null,方便调用方继续拼接)。</summary>
|
||||
internal static string GetDirectoryName(string path)
|
||||
{
|
||||
var p = StripExtended(path);
|
||||
var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var idx = trimmed.LastIndexOfAny(['\\', '/']);
|
||||
if (idx < 0) return p;
|
||||
var parent = trimmed[..idx];
|
||||
// "E:" → "E:\"
|
||||
if (parent.Length == 2 && parent[1] == ':') return parent + Path.DirectorySeparatorChar;
|
||||
if (parent.Length == 0) return @"\";
|
||||
return parent;
|
||||
}
|
||||
|
||||
/// <summary>取卷根(用于同卷判定);UNC 时返回 \\server\share\。</summary>
|
||||
internal static string GetVolumeRoot(string path)
|
||||
{
|
||||
var full = TryGetFullPath(path) ?? StripExtended(path);
|
||||
var root = Path.GetPathRoot(full);
|
||||
return string.IsNullOrEmpty(root) ? full : root;
|
||||
}
|
||||
|
||||
/// <summary>是否同一卷(同卷 Move 才能走瞬时的 File.Move/Directory.Move)。</summary>
|
||||
internal static bool SameVolume(string a, string b)
|
||||
=> string.Equals(GetVolumeRoot(a), GetVolumeRoot(b), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
internal static bool FileExists(string path)
|
||||
{
|
||||
try { return File.Exists(ToExtended(path)); } catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
internal static bool DirectoryExists(string path)
|
||||
{
|
||||
try { return Directory.Exists(ToExtended(path)); } catch (Exception) { return false; }
|
||||
}
|
||||
|
||||
internal static bool Exists(string path) => FileExists(path) || DirectoryExists(path);
|
||||
|
||||
internal static FileAttributes? TryGetAttributes(string path)
|
||||
{
|
||||
try { return File.GetAttributes(ToExtended(path)); }
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
internal static bool IsDirectory(string path)
|
||||
=> (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.Directory) != 0;
|
||||
|
||||
internal static bool IsReparsePoint(string path)
|
||||
=> (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.ReparsePoint) != 0;
|
||||
|
||||
internal static long TryGetLength(string path)
|
||||
{
|
||||
try { return new FileInfo(ToExtended(path)).Length; }
|
||||
catch (Exception) { return 0; }
|
||||
}
|
||||
|
||||
/// <summary>清掉只读属性,否则覆盖/删除会抛 UnauthorizedAccessException。</summary>
|
||||
internal static void ClearReadOnly(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attrs = File.GetAttributes(ToExtended(path));
|
||||
if ((attrs & FileAttributes.ReadOnly) != 0)
|
||||
File.SetAttributes(ToExtended(path), attrs & ~FileAttributes.ReadOnly);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 不存在或无权访问:交给真正的操作去抛错,这里不吞掉信息。
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>确保父目录存在(撤销时目标父目录可能已被删掉)。</summary>
|
||||
internal static void EnsureParentDirectory(string path)
|
||||
{
|
||||
var parent = GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(ToExtended(parent));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成 "名字 (2).ext" 风格的不冲突路径;若本身已带 " (n)" 后缀,先剥离再递增,
|
||||
/// 避免出现 "a (2) (2).txt" 这种叠加命名。
|
||||
/// </summary>
|
||||
internal static string MakeUniquePath(string desiredPath)
|
||||
{
|
||||
if (!Exists(desiredPath)) return desiredPath;
|
||||
|
||||
var directory = GetDirectoryName(desiredPath);
|
||||
var name = GetFileName(desiredPath);
|
||||
var ext = Path.GetExtension(name);
|
||||
var stem = ext.Length > 0 ? name[..^ext.Length] : name;
|
||||
|
||||
var m = CopySuffixRegex().Match(stem);
|
||||
if (m.Success) stem = m.Groups[1].Value;
|
||||
|
||||
for (var i = 2; i < 100_000; i++)
|
||||
{
|
||||
var candidate = Combine(directory, $"{stem} ({i}){ext}");
|
||||
if (!Exists(candidate)) return candidate;
|
||||
}
|
||||
|
||||
throw new IOException($"无法为“{desiredPath}”生成不冲突的新名称。");
|
||||
}
|
||||
|
||||
/// <summary>校验文件名(重命名/新建文件夹用),错误信息为中文。</summary>
|
||||
internal static bool TryValidateFileName(string? name, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
error = "名称不能为空。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.Length > 255)
|
||||
{
|
||||
error = "名称过长(最多 255 个字符)。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
if (name.IndexOfAny(invalid) >= 0)
|
||||
{
|
||||
var bad = new string(name.Where(c => Array.IndexOf(invalid, c) >= 0).Distinct().ToArray());
|
||||
error = $"名称包含非法字符:{bad}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.EndsWith(' ') || name.EndsWith('.'))
|
||||
{
|
||||
error = "名称不能以空格或点结尾。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.TrimEnd(' ', '.').Length == 0)
|
||||
{
|
||||
error = "名称不能只由空格或点组成。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var stem = Path.GetFileNameWithoutExtension(name);
|
||||
if (IsReservedDeviceName(stem))
|
||||
{
|
||||
error = $"“{stem}”是 Windows 保留设备名,不能用作文件名。";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsReservedDeviceName(string stem)
|
||||
{
|
||||
if (stem.Length is 3 or 4)
|
||||
{
|
||||
if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase) ||
|
||||
stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) ||
|
||||
stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) ||
|
||||
stem.Equals("NUL", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (stem.Length == 4 && stem[3] is >= '1' and <= '9')
|
||||
{
|
||||
var head = stem[..3];
|
||||
if (head.Equals("COM", StringComparison.OrdinalIgnoreCase) ||
|
||||
head.Equals("LPT", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安全枚举某个目录的直接子项(返回普通路径)。
|
||||
/// 单个子目录无权限/枚举中途出错时返回已拿到的部分并回调警告,绝不抛出。
|
||||
/// </summary>
|
||||
internal static List<string> EnumerateChildrenSafe(string directory, Action<string>? onWarning = null)
|
||||
{
|
||||
var result = new List<string>();
|
||||
IEnumerator<string>? enumerator = null;
|
||||
try
|
||||
{
|
||||
enumerator = Directory.EnumerateFileSystemEntries(ToExtended(directory)).GetEnumerator();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
onWarning?.Invoke($"无法枚举目录“{directory}”:{ex.Message}");
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
string current;
|
||||
try
|
||||
{
|
||||
if (!enumerator.MoveNext()) break;
|
||||
current = enumerator.Current;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
onWarning?.Invoke($"枚举目录“{directory}”时中断:{ex.Message}");
|
||||
break;
|
||||
}
|
||||
|
||||
result.Add(StripExtended(current));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
enumerator.Dispose();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>把 \\?\ 前缀路径还原成普通路径(供 P/Invoke Shell API 使用)。</summary>
|
||||
internal static string ToShellPath(string path) => StripExtended(path);
|
||||
|
||||
/// <summary>判断 extended 前缀是否已存在(调试用)。</summary>
|
||||
internal static bool HasExtendedPrefix(string path)
|
||||
=> path.StartsWith(ExtendedPrefix, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>分配 UTF-16 双 null 结尾路径列表(SHFileOperationW 要求)。</summary>
|
||||
internal static IntPtr AllocDoubleNullList(IEnumerable<string> paths)
|
||||
{
|
||||
var joined = string.Join('\0', paths) + "\0\0";
|
||||
return Marshal.StringToHGlobalUni(joined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace FluidExplorer.Services.Operations;
|
||||
|
||||
/// <summary>回收站里一条 $I 索引记录解析出来的信息。</summary>
|
||||
internal sealed record RecycleBinItem(string IndexPath, string DataPath, string OriginalPath, long Size, DateTime DeletedUtc);
|
||||
|
||||
/// <summary>
|
||||
/// 回收站定位器:
|
||||
/// 1. 用 shell32!SHFileOperationW(FO_DELETE + FOF_ALLOWUNDO) 把一批路径送进回收站(一次调用完成一批);
|
||||
/// 2. 通过"操作前后 <卷>:\$Recycle.Bin\<SID>\ 目录里 $I* 文件的差集"定位本次新增的回收站条目,
|
||||
/// 解析 $I 结构拿到原始路径,并把 $I 前缀换成 $R 得到回收站内的真实数据路径,
|
||||
/// 从而支持 Ctrl+Z 一步还原。
|
||||
///
|
||||
/// $I 文件结构(Win10+ 为版本 2):
|
||||
/// offset 0 8B 版本号(Win10+ = 2)
|
||||
/// offset 8 8B 原始文件大小
|
||||
/// offset 16 8B 删除时间(FILETIME)
|
||||
/// offset 24 4B 文件名长度(仅版本 >= 2 存在)
|
||||
/// offset 24/28 UTF-16LE 的原始完整路径,以 \0 结尾
|
||||
/// 路径长度字段在不同 Windows 版本上语义有歧义(字符数 / 字节数两种实现都有),
|
||||
/// 因此这里直接读到缓冲区末尾并按第一个 \0 截断,比依赖该字段更稳。
|
||||
/// </summary>
|
||||
internal static partial class RecycleBinLocator
|
||||
{
|
||||
private const uint FO_DELETE = 0x0003;
|
||||
private const ushort FOF_SILENT = 0x0004;
|
||||
private const ushort FOF_NOCONFIRMATION = 0x0010;
|
||||
private const ushort FOF_ALLOWUNDO = 0x0040;
|
||||
private const ushort FOF_NOERRORUI = 0x0400;
|
||||
private const ushort FOF_WANTNUKEWARNING = 0x4000;
|
||||
|
||||
private const ushort DeleteFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI | FOF_WANTNUKEWARNING;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SHFILEOPSTRUCTW
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint wFunc;
|
||||
public IntPtr pFrom;
|
||||
public IntPtr pTo;
|
||||
public ushort fFlags;
|
||||
public int fAnyOperationsAborted;
|
||||
public IntPtr hNameMappings;
|
||||
public IntPtr lpszProgressTitle;
|
||||
}
|
||||
|
||||
// 用传统 DllImport:结构体全是 blittable 字段,不需要 LibraryImport 的 unsafe 代码生成,
|
||||
// 这样本层不引入 AllowUnsafeBlocks 依赖,任何项目链接这些源码都能直接编译。
|
||||
[DllImport("shell32.dll", EntryPoint = "SHFileOperationW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
|
||||
private static extern int SHFileOperation(ref SHFILEOPSTRUCTW lpFileOp);
|
||||
|
||||
// ------------------------------------------------------------ 删除到回收站
|
||||
|
||||
/// <summary>把一批路径送进回收站。必须一次调用完成一批(Shell 语义)。</summary>
|
||||
internal static (bool Success, bool Aborted, int Code) DeleteToRecycleBin(IReadOnlyList<string> paths)
|
||||
{
|
||||
if (paths.Count == 0) return (true, false, 0);
|
||||
|
||||
// 注意:Shell API 只接受普通路径,绝不能带 \\?\ 前缀。
|
||||
var from = PathHelper.AllocDoubleNullList(paths.Select(PathHelper.ToShellPath));
|
||||
var op = new SHFILEOPSTRUCTW
|
||||
{
|
||||
hwnd = IntPtr.Zero,
|
||||
wFunc = FO_DELETE,
|
||||
pFrom = from,
|
||||
pTo = IntPtr.Zero,
|
||||
fFlags = DeleteFlags,
|
||||
fAnyOperationsAborted = 0,
|
||||
hNameMappings = IntPtr.Zero,
|
||||
lpszProgressTitle = IntPtr.Zero
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var code = SHFileOperation(ref op);
|
||||
var aborted = op.fAnyOperationsAborted != 0;
|
||||
return (code == 0 && !aborted, aborted, code);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(from);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在专用 STA 线程上执行 SHFileOperationW。
|
||||
/// Shell 函数在内部会做 COM/OLE 相关工作,用 STA 线程调用最稳妥;
|
||||
/// 该线程是后台线程,不会阻塞 UI,也不会阻止进程退出。
|
||||
/// </summary>
|
||||
internal static Task<(bool Success, bool Aborted, int Code)> DeleteToRecycleBinAsync(IReadOnlyList<string> paths)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<(bool Success, bool Aborted, int Code)>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
tcs.TrySetResult(DeleteToRecycleBin(paths));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "FluidExplorer-RecycleBin"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
// 非 Windows 平台(本引擎实际只跑 Windows):直接以 MTA 启动。
|
||||
}
|
||||
|
||||
thread.Start();
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
internal static string DescribeResult(int code) => code switch
|
||||
{
|
||||
0 => "成功",
|
||||
2 => "找不到指定的文件。",
|
||||
3 => "找不到指定的路径。",
|
||||
5 => "拒绝访问。",
|
||||
0x20 => "共享冲突(文件正被其它进程使用)。",
|
||||
0x71 => "源与目标是同一个文件。",
|
||||
0x72 => "多个源文件对应单个目标(目录)。",
|
||||
0x73 => "源与目标处于不同目录。",
|
||||
0x74 => "不能对根目录执行该操作。",
|
||||
0x75 => "操作已被取消。",
|
||||
0x76 => "目标位于源的子树中。",
|
||||
0x78 => "访问源文件被拒绝。",
|
||||
0x79 => "路径层级过深。",
|
||||
0x7A => "目标过多。",
|
||||
0x7C => "存在无效文件名。",
|
||||
0x7D => "目标与源在同一目录树内。",
|
||||
0x7E => "目标为文件,但源为文件夹。",
|
||||
0x80 => "目标为文件夹,但源为文件。",
|
||||
0x81 => "文件名过长。",
|
||||
0x82 => "目标磁盘为 CD-ROM。",
|
||||
0x83 => "目标磁盘为 DVD。",
|
||||
0x84 => "目标磁盘为可刻录光盘。",
|
||||
0x85 => "文件过大。",
|
||||
0x86 => "源磁盘为 CD-ROM。",
|
||||
0x87 => "源磁盘为 DVD。",
|
||||
0x88 => "源磁盘为可刻录光盘。",
|
||||
0xB7 => "超过文件名/路径长度上限。",
|
||||
0x10000 => "目标上发生未指明的错误。",
|
||||
_ => $"SHFileOperation 返回错误码 0x{code:X}。"
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------ $I / $R 定位
|
||||
|
||||
/// <summary>取当前进程用户的 SID 字符串(回收站目录名)。</summary>
|
||||
internal static string? TryGetCurrentUserSid()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return identity.User?.Value;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>某个卷的回收站目录:<卷>:\$Recycle.Bin\<SID>\(不存在返回 null)。</summary>
|
||||
internal static string? GetRecycleBinDirectory(string volumeRoot)
|
||||
{
|
||||
var sid = TryGetCurrentUserSid();
|
||||
if (string.IsNullOrEmpty(sid)) return null;
|
||||
var dir = PathHelper.Combine(PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin"), sid);
|
||||
return PathHelper.DirectoryExists(dir) ? dir : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 快照:卷 → 该卷回收站里所有 $I 文件的完整路径集合。
|
||||
/// 主体扫描 <卷>:\$Recycle.Bin\<当前用户 SID>\,同时兜底扫描其它 SID 目录
|
||||
/// (进程可能以别的账户删除过文件)。
|
||||
/// </summary>
|
||||
internal static Dictionary<string, HashSet<string>> CaptureState(IReadOnlyList<string> paths)
|
||||
{
|
||||
var volumes = new List<string>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var root = PathHelper.GetVolumeRoot(path);
|
||||
if (root.Length < 2) continue;
|
||||
if (!volumes.Contains(root, StringComparer.OrdinalIgnoreCase)) volumes.Add(root);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var volume in volumes) result[volume] = EnumerateIndexFiles(volume);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static HashSet<string> EnumerateIndexFiles(string volumeRoot)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var binRoot = PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin");
|
||||
if (!PathHelper.DirectoryExists(binRoot)) return set;
|
||||
|
||||
var directories = new List<string>();
|
||||
|
||||
var ownSidDirectory = GetRecycleBinDirectory(volumeRoot);
|
||||
if (ownSidDirectory is not null) directories.Add(ownSidDirectory);
|
||||
|
||||
foreach (var sub in PathHelper.EnumerateChildrenSafe(binRoot))
|
||||
{
|
||||
if (!PathHelper.DirectoryExists(sub)) continue;
|
||||
if (!directories.Contains(sub, StringComparer.OrdinalIgnoreCase)) directories.Add(sub);
|
||||
}
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
foreach (var file in PathHelper.EnumerateChildrenSafe(directory))
|
||||
{
|
||||
var name = PathHelper.GetFileName(file);
|
||||
if (name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) set.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用"操作前后目录快照差集"找出本次新增的回收站条目,并用 $I 里的原始路径做二次确认,
|
||||
/// 产出 (回收站内 $R 数据路径, 原始路径) 列表,可直接填入 <see cref="UndoEntry.Deleted"/>。
|
||||
/// 定位失败的项也会产出记录(回收站路径为空串),撤销时按"无法还原"计入失败,不会崩溃。
|
||||
/// </summary>
|
||||
internal static (List<(string RecyclePath, string OriginalPath)> Items, List<string> Diagnostics) ResolveDeletedItems(
|
||||
IReadOnlyList<string> deletedPaths,
|
||||
Dictionary<string, HashSet<string>> before)
|
||||
{
|
||||
var items = new List<(string RecyclePath, string OriginalPath)>();
|
||||
var diagnostics = new List<string>();
|
||||
var remaining = new List<string>(deletedPaths);
|
||||
|
||||
foreach (var (volume, previous) in before)
|
||||
{
|
||||
var current = EnumerateIndexFiles(volume);
|
||||
var added = current.Where(f => !previous.Contains(f)).OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (added.Count == 0) continue;
|
||||
|
||||
foreach (var indexFile in added)
|
||||
{
|
||||
var parsed = TryParseIndexFile(indexFile);
|
||||
if (parsed is null)
|
||||
{
|
||||
diagnostics.Add($"$I 解析失败(长度/格式异常):{indexFile}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 匹配策略:先按原始完整路径精确匹配,再退化为"同名文件"匹配
|
||||
// (一次 SHFileOperation 调用内完成的条目,时间窗天然一致)。
|
||||
var match = remaining.FirstOrDefault(p => SamePath(p, parsed.OriginalPath))
|
||||
?? remaining.FirstOrDefault(p => string.Equals(
|
||||
PathHelper.GetFileName(p), PathHelper.GetFileName(parsed.OriginalPath), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
diagnostics.Add($"回收站新增条目未能匹配本次删除路径:{indexFile}(原始路径 {parsed.OriginalPath})");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!PathHelper.Exists(parsed.DataPath))
|
||||
diagnostics.Add($"找到 $I 记录但缺少对应的 $R 数据文件:{parsed.DataPath}");
|
||||
|
||||
items.Add((parsed.DataPath, match));
|
||||
remaining.Remove(match);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var path in remaining)
|
||||
{
|
||||
diagnostics.Add($"未能在回收站定位到“{path}”的 $I/$R 记录(可能被永久删除或回收站不可用),撤销时该项将按“无法还原”处理。");
|
||||
items.Add((string.Empty, path));
|
||||
}
|
||||
|
||||
return (items, diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>解析单个 $I 索引文件。</summary>
|
||||
internal static RecycleBinItem? TryParseIndexFile(string indexFilePath)
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = File.ReadAllBytes(PathHelper.ToExtended(indexFilePath));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bytes.Length < 28) return null;
|
||||
|
||||
var version = BitConverter.ToInt64(bytes, 0);
|
||||
var size = BitConverter.ToInt64(bytes, 8);
|
||||
var fileTime = BitConverter.ToInt64(bytes, 16);
|
||||
|
||||
DateTime deletedUtc;
|
||||
try
|
||||
{
|
||||
deletedUtc = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
deletedUtc = DateTime.MinValue;
|
||||
}
|
||||
|
||||
// 版本 1(Vista/7)无文件名长度字段;版本 2(Win10+)多 4 字节。
|
||||
var pathOffset = version >= 2 ? 28 : 24;
|
||||
if (bytes.Length <= pathOffset) return null;
|
||||
|
||||
var payload = bytes.AsSpan(pathOffset);
|
||||
if ((payload.Length & 1) == 1) payload = payload[..^1]; // UTF-16 按 2 字节对齐
|
||||
|
||||
var chars = MemoryMarshal.Cast<byte, char>(payload);
|
||||
var terminator = chars.IndexOf('\0');
|
||||
if (terminator >= 0) chars = chars[..terminator];
|
||||
if (chars.Length == 0) return null;
|
||||
|
||||
var originalPath = new string(chars);
|
||||
var name = PathHelper.GetFileName(indexFilePath);
|
||||
if (name.Length < 3 || !name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
// $R 对应文件:把 $I 换成 $R 前缀即为回收站内的实际数据路径(目录同样适用)。
|
||||
var dataPath = PathHelper.Combine(PathHelper.GetDirectoryName(indexFilePath), "$R" + name[2..]);
|
||||
|
||||
return new RecycleBinItem(indexFilePath, dataPath, originalPath, size, deletedUtc);
|
||||
}
|
||||
|
||||
/// <summary>由 $R 数据路径反推对应的 $I 索引路径。</summary>
|
||||
internal static string? GetIndexPathFromDataPath(string recycleDataPath)
|
||||
{
|
||||
var name = PathHelper.GetFileName(recycleDataPath);
|
||||
if (name.Length < 3 || !name.StartsWith("$R", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
return PathHelper.Combine(PathHelper.GetDirectoryName(recycleDataPath), "$I" + name[2..]);
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b)
|
||||
{
|
||||
var na = (PathHelper.TryGetFullPath(a) ?? a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var nb = (PathHelper.TryGetFullPath(b) ?? b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <= 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace FluidExplorer.Services.Search.Usn;
|
||||
|
||||
/// <summary>
|
||||
/// 紧凑文件名池 —— 索引“快且省内存”的关键之一。
|
||||
///
|
||||
/// 所有文件名以 UTF-16 连续存放在若干 <b>定长块</b>(每块 1<<20 个字符 = 2MB)里,
|
||||
/// 整个索引里不会为文件名产生任何 string 对象;查询时直接在这块内存上取 ReadOnlySpan<char>。
|
||||
///
|
||||
/// 偏移编码:<c>(chunkIndex << 20) | offsetInChunk</c>,单个 int 即可寻址 2G 字符。
|
||||
/// 因为块大小固定为 1<<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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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\<SID>\$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()} 文件";
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user