From 5780fde61a2314d410cb77fbb6444a46527f4f27 Mon Sep 17 00:00:00 2001
From: WpyQwq <3911625973@qq.com>
Date: Sat, 19 Sep 2026 11:54:03 +0800
Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20FluidExplorer=EF=BC=9A?=
=?UTF-8?q?=E4=BB=8E=E9=9B=B6=E5=AE=9E=E7=8E=B0=E7=9A=84=20WinUI=203=20?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E5=99=A8?=
=?UTF-8?q?=E6=9B=BF=E4=BB=A3=E5=93=81=EF=BC=88Mica=E3=80=81=E5=91=BD?=
=?UTF-8?q?=E4=BB=A4=E6=A0=8F=E3=80=81=E9=9D=A2=E5=8C=85=E5=B1=91=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
App.xaml | 16 +
App.xaml.cs | 94 +
FluidExplorer.csproj | 35 +
Helpers/Converters.cs | 114 ++
Helpers/DispatcherQueueExtensions.cs | 51 +
MainWindow.xaml | 14 +
MainWindow.xaml.cs | 269 +++
Models/FileEntry.cs | 158 ++
Models/SortSpec.cs | 50 +
Navigation/NavigationLocation.cs | 85 +
README.md | 151 ++
Services/AppServices.cs | 74 +
Services/AppSettings.cs | 153 ++
Services/FileSystem/FastFileSystemService.cs | 223 +++
Services/FileSystem/IFileSystemService.cs | 30 +
Services/Icons/IIconService.cs | 31 +
Services/Icons/ShellIconService.cs | 894 ++++++++++
Services/Icons/ShellNative.cs | 1151 ++++++++++++
Services/Icons/ShellParsingName.cs | 49 +
Services/ItemVisuals/ItemVisualService.cs | 169 ++
Services/Operations/CopyEngine.cs | 690 ++++++++
Services/Operations/FileOperationService.cs | 922 ++++++++++
Services/Operations/IFileOperationService.cs | 167 ++
Services/Operations/JobQueue.cs | 282 +++
Services/Operations/PathHelper.cs | 330 ++++
Services/Operations/RecycleBinLocator.cs | 353 ++++
Services/Placeholders.cs | 83 +
Services/Search/IFileIndex.cs | 77 +
Services/Search/SearchQuery.cs | 200 +++
Services/Search/SearchService.cs | 237 +++
Services/Search/Usn/DirectoryChangeWatcher.cs | 243 +++
Services/Search/Usn/IndexStore.cs | 212 +++
Services/Search/Usn/MftReader.cs | 354 ++++
Services/Search/Usn/NamePool.cs | 83 +
Services/Search/Usn/UsnNative.cs | 458 +++++
Services/Search/Usn/UsnVolumeIndex.cs | 1565 +++++++++++++++++
Services/Search/Usn/WildcardMatcher.cs | 107 ++
Services/Shell/KnownFolders.cs | 116 ++
Services/Shell/NaturalStringComparer.cs | 30 +
Services/Shell/RecycleBinView.cs | 109 ++
Services/Shell/ShellActions.cs | 223 +++
Services/Shell/ShellContextMenu.cs | 267 +++
Services/Shell/TypeNameResolver.cs | 60 +
Themes/Glyphs.xaml | 55 +
Themes/Styles.xaml | 79 +
ViewModels/ExplorerPaneViewModel.cs | 1134 ++++++++++++
ViewModels/ExplorerTabViewModel.cs | 111 ++
ViewModels/JobRowViewModel.cs | 64 +
ViewModels/MainViewModel.cs | 500 ++++++
ViewModels/SidebarNode.cs | 97 +
Views/ExplorerPaneView.xaml | 508 ++++++
Views/ExplorerPaneView.xaml.cs | 800 +++++++++
Views/ExplorerTabView.xaml | 33 +
Views/ExplorerTabView.xaml.cs | 98 ++
Views/SettingsDialog.xaml | 51 +
Views/SettingsDialog.xaml.cs | 140 ++
Views/ShellView.xaml | 126 ++
Views/ShellView.xaml.cs | 237 +++
app.manifest | 16 +
nuget.config | 7 +
60 files changed, 15035 insertions(+)
create mode 100644 App.xaml
create mode 100644 App.xaml.cs
create mode 100644 FluidExplorer.csproj
create mode 100644 Helpers/Converters.cs
create mode 100644 Helpers/DispatcherQueueExtensions.cs
create mode 100644 MainWindow.xaml
create mode 100644 MainWindow.xaml.cs
create mode 100644 Models/FileEntry.cs
create mode 100644 Models/SortSpec.cs
create mode 100644 Navigation/NavigationLocation.cs
create mode 100644 README.md
create mode 100644 Services/AppServices.cs
create mode 100644 Services/AppSettings.cs
create mode 100644 Services/FileSystem/FastFileSystemService.cs
create mode 100644 Services/FileSystem/IFileSystemService.cs
create mode 100644 Services/Icons/IIconService.cs
create mode 100644 Services/Icons/ShellIconService.cs
create mode 100644 Services/Icons/ShellNative.cs
create mode 100644 Services/Icons/ShellParsingName.cs
create mode 100644 Services/ItemVisuals/ItemVisualService.cs
create mode 100644 Services/Operations/CopyEngine.cs
create mode 100644 Services/Operations/FileOperationService.cs
create mode 100644 Services/Operations/IFileOperationService.cs
create mode 100644 Services/Operations/JobQueue.cs
create mode 100644 Services/Operations/PathHelper.cs
create mode 100644 Services/Operations/RecycleBinLocator.cs
create mode 100644 Services/Placeholders.cs
create mode 100644 Services/Search/IFileIndex.cs
create mode 100644 Services/Search/SearchQuery.cs
create mode 100644 Services/Search/SearchService.cs
create mode 100644 Services/Search/Usn/DirectoryChangeWatcher.cs
create mode 100644 Services/Search/Usn/IndexStore.cs
create mode 100644 Services/Search/Usn/MftReader.cs
create mode 100644 Services/Search/Usn/NamePool.cs
create mode 100644 Services/Search/Usn/UsnNative.cs
create mode 100644 Services/Search/Usn/UsnVolumeIndex.cs
create mode 100644 Services/Search/Usn/WildcardMatcher.cs
create mode 100644 Services/Shell/KnownFolders.cs
create mode 100644 Services/Shell/NaturalStringComparer.cs
create mode 100644 Services/Shell/RecycleBinView.cs
create mode 100644 Services/Shell/ShellActions.cs
create mode 100644 Services/Shell/ShellContextMenu.cs
create mode 100644 Services/Shell/TypeNameResolver.cs
create mode 100644 Themes/Glyphs.xaml
create mode 100644 Themes/Styles.xaml
create mode 100644 ViewModels/ExplorerPaneViewModel.cs
create mode 100644 ViewModels/ExplorerTabViewModel.cs
create mode 100644 ViewModels/JobRowViewModel.cs
create mode 100644 ViewModels/MainViewModel.cs
create mode 100644 ViewModels/SidebarNode.cs
create mode 100644 Views/ExplorerPaneView.xaml
create mode 100644 Views/ExplorerPaneView.xaml.cs
create mode 100644 Views/ExplorerTabView.xaml
create mode 100644 Views/ExplorerTabView.xaml.cs
create mode 100644 Views/SettingsDialog.xaml
create mode 100644 Views/SettingsDialog.xaml.cs
create mode 100644 Views/ShellView.xaml
create mode 100644 Views/ShellView.xaml.cs
create mode 100644 app.manifest
create mode 100644 nuget.config
diff --git a/App.xaml b/App.xaml
new file mode 100644
index 0000000..8efae7c
--- /dev/null
+++ b/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App.xaml.cs b/App.xaml.cs
new file mode 100644
index 0000000..51dbb5d
--- /dev/null
+++ b/App.xaml.cs
@@ -0,0 +1,94 @@
+using FluidExplorer.Services;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+
+namespace FluidExplorer;
+
+public partial class App : Application
+{
+ /// 启动阶段日志:WinUI 启动期的异常在窗口出现前就终止进程,必须落盘才能定位。
+ private static readonly string LogPath = Path.Combine(AppContext.BaseDirectory, "startup.log");
+
+ public App()
+ {
+ Log("App ctor: begin");
+ InitializeComponent();
+ Log("App ctor: InitializeComponent done");
+ UnhandledException += OnUnhandledException;
+ AppDomain.CurrentDomain.UnhandledException += (_, e) => Log($"AppDomain unhandled: {e.ExceptionObject}");
+ TaskScheduler.UnobservedTaskException += (_, e) =>
+ {
+ Log($"Unobserved task exception: {e.Exception}");
+ e.SetObserved();
+ };
+
+ // 诊断开关:宿主/非托管路径上的异常不会走 UnhandledException,需要首次异常钩子才能看到来源
+ if (Environment.GetEnvironmentVariable("FLUID_DEBUG_EXPLOG") == "1")
+ {
+ AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
+ {
+ var ex = e.Exception;
+ var stack = ex.StackTrace ?? string.Empty;
+ if (stack.Contains("FluidExplorer", StringComparison.Ordinal) || ex.Source?.Contains("FluidExplorer", StringComparison.Ordinal) == true)
+ Log($"first-chance {ex.GetType().FullName}: {ex.Message}{Environment.NewLine}{stack}");
+ };
+ }
+ }
+
+ public static AppServices Services { get; private set; } = null!;
+
+ /// 当前主窗口(原版右键菜单、窗口图标、对话框都需要它的 HWND / XamlRoot)。
+ public static MainWindow MainWindowInstance { get; private set; } = null!;
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ try
+ {
+ Log("OnLaunched: begin");
+ Services = new AppServices(DispatcherQueue.GetForCurrentThread());
+ Log("OnLaunched: services ready");
+ Services.WireSearchIndex();
+ Log("OnLaunched: search index wired");
+
+ MainWindowInstance = new MainWindow(Services);
+ Log("OnLaunched: window constructed");
+ MainWindowInstance.Activate();
+ Log("OnLaunched: window activated");
+ }
+ catch (Exception ex)
+ {
+ Log($"OnLaunched FAILED: {ex}");
+ throw;
+ }
+ }
+
+ internal static void Log(string message)
+ {
+ try
+ {
+ File.AppendAllText(LogPath, $"[{DateTime.Now:HH:mm:ss.fff}] {message}{Environment.NewLine}");
+ }
+ catch
+ {
+ // 日志失败绝不能影响主流程
+ }
+ }
+
+ private static void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
+ {
+ Log($"XAML unhandled: {e.Message}{Environment.NewLine}{e.Exception}");
+ try
+ {
+ var log = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "FluidExplorer", "crash.log");
+ Directory.CreateDirectory(Path.GetDirectoryName(log)!);
+ File.AppendAllText(log, $"[{DateTime.Now:O}] {e.Message}\n{e.Exception}\n\n");
+ }
+ catch
+ {
+ // 忽略
+ }
+ e.Handled = true;
+ }
+}
diff --git a/FluidExplorer.csproj b/FluidExplorer.csproj
new file mode 100644
index 0000000..f352786
--- /dev/null
+++ b/FluidExplorer.csproj
@@ -0,0 +1,35 @@
+
+
+
+ WinExe
+ net8.0-windows10.0.26100.0
+ 10.0.19041.0
+ FluidExplorer
+ FluidExplorer
+ app.manifest
+ x64;ARM64
+ win-x64
+ true
+ None
+ true
+ false
+ 10.0.26100.57
+ enable
+ latest
+ enable
+ true
+ false
+ false
+ false
+ false
+
+ $(NoWarn);MVVMTK0045
+
+
+
+
+
+
+
+
+
diff --git a/Helpers/Converters.cs b/Helpers/Converters.cs
new file mode 100644
index 0000000..ad0d78e
--- /dev/null
+++ b/Helpers/Converters.cs
@@ -0,0 +1,114 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+
+namespace FluidExplorer.Helpers;
+
+/// bool → Visibility(ConverterParameter="invert" 可反转)。
+public sealed class BoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ var flag = value is bool b && b;
+ if (parameter is string s && s.Equals("invert", StringComparison.OrdinalIgnoreCase)) flag = !flag;
+ return flag ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => value is Visibility v && v == Visibility.Visible;
+}
+
+/// 字符串非空 → Visible。
+public sealed class StringToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ => string.IsNullOrWhiteSpace(value as string) ? Visibility.Collapsed : Visibility.Visible;
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
+
+/// 数量 > 0 → Visible。
+public sealed class CountToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ => value is int count && count > 0 ? Visibility.Visible : Visibility.Collapsed;
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
+
+/// bool 取反。
+public sealed class InverseBoolConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ => value is bool b && !b;
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => value is bool b && !b;
+}
+
+/// 回收站/图库等视图才显示的操作按钮。
+public sealed class KindToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ var current = value?.ToString();
+ var expected = parameter as string;
+ return string.Equals(current, expected, StringComparison.OrdinalIgnoreCase) ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
+
+/// 作业状态 → 中文文案(状态栏/队列面板用)。
+public sealed class JobStateTextConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ => value is Services.Operations.JobState state
+ ? state switch
+ {
+ Services.Operations.JobState.Queued => "排队中",
+ Services.Operations.JobState.Running => "进行中",
+ Services.Operations.JobState.Paused => "已暂停",
+ Services.Operations.JobState.Completed => "已完成",
+ Services.Operations.JobState.CompletedWithErrors => "已完成(部分出错)",
+ Services.Operations.JobState.Cancelled => "已取消",
+ Services.Operations.JobState.Failed => "失败",
+ _ => state.ToString()
+ }
+ : string.Empty;
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
+
+/// 作业 → 进度明细文案:条目数 / 体积 / 速度 / 剩余时间。
+public sealed class JobProgressTextConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is not Services.Operations.FileOperationJob job) return string.Empty;
+ var parts = new List { $"{job.CompletedItems:N0} / {job.TotalItems:N0} 个项目" };
+ if (job.TotalBytes > 0)
+ parts.Add($"{Models.FileEntry.FormatSize(job.CompletedBytes)} / {Models.FileEntry.FormatSize(job.TotalBytes)}");
+ if (job.BytesPerSecond > 1) parts.Add($"{Models.FileEntry.FormatSize((long)job.BytesPerSecond)}/s");
+ if (job.Eta is { } eta && eta.TotalSeconds > 1 && eta.TotalHours < 24)
+ parts.Add($"剩余 {eta:mm\\:ss}");
+ if (!string.IsNullOrEmpty(job.Error)) parts.Add(job.Error!);
+ return string.Join(" · ", parts);
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
+
+/// 作业 → 暂停/继续按钮图标。
+public sealed class JobPauseGlyphConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ => value is Services.Operations.JobState.Paused ? "\uE768" : "\uE769";
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
diff --git a/Helpers/DispatcherQueueExtensions.cs b/Helpers/DispatcherQueueExtensions.cs
new file mode 100644
index 0000000..cf9baeb
--- /dev/null
+++ b/Helpers/DispatcherQueueExtensions.cs
@@ -0,0 +1,51 @@
+using Microsoft.UI.Dispatching;
+
+namespace FluidExplorer.Helpers;
+
+/// DispatcherQueue 的 await 化封装:把 UI 线程更新变成可等待的操作,便于批量提交与顺序保证。
+public static class DispatcherQueueExtensions
+{
+ public static Task EnqueueAsync(this DispatcherQueue queue, Action action)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (!queue.TryEnqueue(() =>
+ {
+ try
+ {
+ action();
+ tcs.TrySetResult();
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetException(ex);
+ }
+ }))
+ {
+ tcs.TrySetException(new InvalidOperationException("DispatcherQueue 已关闭,无法调度到 UI 线程。"));
+ }
+ return tcs.Task;
+ }
+
+ public static Task EnqueueAsync(this DispatcherQueue queue, Func func)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (!queue.TryEnqueue(() =>
+ {
+ try
+ {
+ tcs.TrySetResult(func());
+ }
+ catch (Exception ex)
+ {
+ tcs.TrySetException(ex);
+ }
+ }))
+ {
+ tcs.TrySetException(new InvalidOperationException("DispatcherQueue 已关闭,无法调度到 UI 线程。"));
+ }
+ return tcs.Task;
+ }
+
+ /// 即发即忘的 UI 调度(不需要等待结果的场合)。
+ public static void Post(this DispatcherQueue queue, Action action) => queue.TryEnqueue(() => action());
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
new file mode 100644
index 0000000..8ca1b94
--- /dev/null
+++ b/MainWindow.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
new file mode 100644
index 0000000..c564ade
--- /dev/null
+++ b/MainWindow.xaml.cs
@@ -0,0 +1,269 @@
+using FluidExplorer.Services;
+using FluidExplorer.Services.Operations;
+using FluidExplorer.ViewModels;
+using Microsoft.UI;
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Windows.Graphics;
+using Windows.System;
+
+namespace FluidExplorer;
+
+public sealed partial class MainWindow : Window
+{
+ private readonly AppServices _services;
+ private readonly AppSettings _settings;
+
+ public MainWindow(AppServices services)
+ {
+ _services = services;
+ _settings = services.Settings;
+ ViewModel = services.Main;
+
+ InitializeComponent();
+ Shell.ViewModel = ViewModel;
+
+ Title = "文件资源管理器";
+ SystemBackdrop = new MicaBackdrop();
+ ExtendsContentIntoTitleBar = true;
+ SetTitleBar(Shell.DragRegion);
+
+ RestoreWindowPlacement();
+ ApplyCaptionButtonColors();
+ HookServices();
+
+ Shell.ThemedRoot.ActualThemeChanged += (_, _) => ApplyCaptionButtonColors();
+ Shell.SizeChanged += (_, _) => UpdateTitleBarInsets();
+ AppWindow.Changed += (_, _) => UpdateTitleBarInsets();
+ AppWindow.Closing += OnClosing;
+
+ RegisterAccelerators();
+
+ // 启动:恢复标签页 + 建立索引(都在后台,窗口先出来)
+ _ = DispatcherQueue.TryEnqueue(async () => await ViewModel.InitializeAsync());
+ }
+
+ public MainViewModel ViewModel { get; }
+
+ public void ShowOperationQueue() => Shell.ShowOperationQueue();
+
+ // ── 窗口外观 ────────────────────────────────────────────────────────────
+ private void RestoreWindowPlacement()
+ {
+ if (!double.IsNaN(_settings.WindowWidth) && _settings.WindowWidth > 400)
+ {
+ AppWindow.ResizeClient(new SizeInt32((int)_settings.WindowWidth, (int)_settings.WindowHeight));
+ }
+ if (!double.IsNaN(_settings.WindowLeft) && !double.IsNaN(_settings.WindowTop) && IsOnScreen(_settings.WindowLeft, _settings.WindowTop))
+ {
+ AppWindow.Move(new PointInt32((int)_settings.WindowLeft, (int)_settings.WindowTop));
+ }
+ if (_settings.WindowMaximized && AppWindow.Presenter is OverlappedPresenter presenter)
+ {
+ presenter.Maximize();
+ }
+ UpdateTitleBarInsets();
+ }
+
+ private static bool IsOnScreen(double left, double top)
+ {
+ var display = DisplayArea.GetFromPoint(new PointInt32((int)left, (int)top), DisplayAreaFallback.Nearest);
+ return display is not null && left >= display.WorkArea.X - 40 && top >= display.WorkArea.Y - 40;
+ }
+
+ /// 把标签栏右侧的留白让给系统标题栏按钮,保证标签不被按钮压住。
+ private void UpdateTitleBarInsets()
+ {
+ try
+ {
+ var scale = Shell.XamlRoot?.RasterizationScale ?? 1.0;
+ var rightInset = AppWindow.TitleBar.RightInset / scale;
+ if (rightInset < 0) rightInset = 0;
+ if (Shell.DragRegion is FrameworkElement region) region.Margin = new Thickness(0, 0, rightInset, 0);
+ }
+ catch
+ {
+ // 最小化/全屏切换等瞬态下取不到 TitleBar 信息,忽略
+ }
+ }
+
+ /// 标题栏按钮颜色跟随当前主题。
+ private void ApplyCaptionButtonColors()
+ {
+ var titleBar = AppWindow.TitleBar;
+ titleBar.ButtonBackgroundColor = Colors.Transparent;
+ titleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
+
+ if (Shell.ThemedRoot.ActualTheme == ElementTheme.Dark)
+ {
+ titleBar.ButtonForegroundColor = Colors.White;
+ titleBar.ButtonHoverBackgroundColor = Windows.UI.Color.FromArgb(24, 255, 255, 255);
+ titleBar.ButtonHoverForegroundColor = Colors.White;
+ titleBar.ButtonPressedBackgroundColor = Windows.UI.Color.FromArgb(48, 255, 255, 255);
+ titleBar.ButtonPressedForegroundColor = Windows.UI.Color.FromArgb(200, 255, 255, 255);
+ titleBar.ButtonInactiveForegroundColor = Windows.UI.Color.FromArgb(120, 255, 255, 255);
+ }
+ else
+ {
+ titleBar.ButtonForegroundColor = Windows.UI.Color.FromArgb(230, 0, 0, 0);
+ titleBar.ButtonHoverBackgroundColor = Windows.UI.Color.FromArgb(20, 0, 0, 0);
+ titleBar.ButtonHoverForegroundColor = Colors.Black;
+ titleBar.ButtonPressedBackgroundColor = Windows.UI.Color.FromArgb(40, 0, 0, 0);
+ titleBar.ButtonPressedForegroundColor = Windows.UI.Color.FromArgb(160, 0, 0, 0);
+ titleBar.ButtonInactiveForegroundColor = Windows.UI.Color.FromArgb(100, 0, 0, 0);
+ }
+ }
+
+ /// 主题切换(用户手动指定时覆盖系统跟随)。
+ public void ApplyThemeMode(AppThemeMode mode)
+ {
+ Shell.ThemedRoot.RequestedTheme = mode switch
+ {
+ AppThemeMode.Light => ElementTheme.Light,
+ AppThemeMode.Dark => ElementTheme.Dark,
+ _ => ElementTheme.Default
+ };
+ }
+
+ // ── 服务接线 ────────────────────────────────────────────────────────────
+ private void HookServices()
+ {
+ _services.Operations.ConflictResolver = ShowConflictDialogAsync;
+ ViewModel.ThemeModeChanged += (_, mode) => ApplyThemeMode(mode);
+ ViewModel.AnimationsToggled += (_, enabled) =>
+ {
+ // 关掉动画时禁用所有依赖动画(WinUI 原生总开关,克制且彻底)
+ Microsoft.UI.Xaml.Media.Animation.Timeline.AllowDependentAnimations = enabled;
+ };
+ ViewModel.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(MainViewModel.ErrorMessage) && ViewModel.ErrorMessage is { Length: > 0 } message)
+ {
+ ViewModel.ActivePane?.ReportError(message);
+ ViewModel.ErrorMessage = null;
+ }
+ };
+ }
+
+ /// 文件冲突对话框:不弹系统模态框,全部由应用自己处理。
+ private async Task ShowConflictDialogAsync(ConflictInfo info)
+ {
+ var dialog = new ContentDialog
+ {
+ XamlRoot = Shell.XamlRoot,
+ Title = "目标位置已有同名项目",
+ DefaultButton = ContentDialogButton.Primary
+ };
+
+ var keepBoth = new RadioButton { Content = "同时保留两个文件(推荐)", IsChecked = true };
+ var replace = new RadioButton { Content = "替换目标中的文件" };
+ var skip = new RadioButton { Content = "跳过此文件" };
+ var applyAll = new CheckBox { Content = "为后续所有冲突执行相同操作" };
+
+ var detail = new StackPanel { Spacing = 8 };
+ detail.Children.Add(new TextBlock
+ {
+ Text = Path.GetFileName(info.SourcePath),
+ TextWrapping = TextWrapping.Wrap,
+ Style = (Style)Application.Current.Resources["BodyStrongTextBlockStyle"]
+ });
+ detail.Children.Add(new TextBlock
+ {
+ Text = $"源:{info.SourcePath}\n{Models.FileEntry.FormatSize(info.SourceSize)} · {info.SourceModifiedUtc.ToLocalTime():yyyy/MM/dd HH:mm}\n\n" +
+ $"目标:{info.DestinationPath}\n{Models.FileEntry.FormatSize(info.DestinationSize)} · {info.DestinationModifiedUtc.ToLocalTime():yyyy/MM/dd HH:mm}",
+ TextWrapping = TextWrapping.Wrap,
+ Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"]
+ });
+ detail.Children.Add(keepBoth);
+ detail.Children.Add(replace);
+ detail.Children.Add(skip);
+ detail.Children.Add(applyAll);
+
+ dialog.Content = detail;
+ dialog.PrimaryButtonText = "继续";
+ dialog.CloseButtonText = "取消本次操作";
+
+ var result = await dialog.ShowAsync();
+ info.ApplyToAll = applyAll.IsChecked == true;
+ if (result != ContentDialogResult.Primary) return ConflictResolution.Cancel;
+ if (replace.IsChecked == true) return ConflictResolution.Replace;
+ if (skip.IsChecked == true) return ConflictResolution.Skip;
+ return ConflictResolution.KeepBoth;
+ }
+
+ // ── 快捷键 ──────────────────────────────────────────────────────────────
+ private void RegisterAccelerators()
+ {
+ AddAccelerator(VirtualKey.T, VirtualKeyModifiers.Control, () => ViewModel.NewTabCommand.Execute(null));
+ AddAccelerator(VirtualKey.W, VirtualKeyModifiers.Control, () => ViewModel.CloseTabCommand.Execute(ViewModel.SelectedTab));
+ AddAccelerator(VirtualKey.Z, VirtualKeyModifiers.Control, () => ViewModel.UndoCommand.Execute(null));
+ AddAccelerator(VirtualKey.D, VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, () => ViewModel.ToggleDualPaneCommand.Execute(null));
+ AddAccelerator(VirtualKey.F, VirtualKeyModifiers.Control, () => ViewModel.ActivePane?.RequestSearchFocus());
+ AddAccelerator(VirtualKey.Tab, VirtualKeyModifiers.Control, CycleTab);
+ }
+
+ private void AddAccelerator(VirtualKey key, VirtualKeyModifiers modifiers, Action action)
+ {
+ var accelerator = new KeyboardAccelerator { Key = key, Modifiers = modifiers };
+ accelerator.Invoked += (_, args) =>
+ {
+ // 文本输入状态下不抢 Ctrl+Z / Ctrl+F
+ if (key is VirtualKey.Z or VirtualKey.F
+ && modifiers == VirtualKeyModifiers.Control
+ && FocusManager.GetFocusedElement(Shell.XamlRoot) is TextBox)
+ {
+ return;
+ }
+ args.Handled = true;
+ action();
+ };
+ Shell.ThemedRoot.KeyboardAccelerators.Add(accelerator);
+ }
+
+ private void CycleTab()
+ {
+ if (ViewModel.Tabs.Count < 2 || ViewModel.SelectedTab is null) return;
+ var index = ViewModel.Tabs.IndexOf(ViewModel.SelectedTab);
+ ViewModel.SelectedTab = ViewModel.Tabs[(index + 1) % ViewModel.Tabs.Count];
+ }
+
+ // ── 关闭 ────────────────────────────────────────────────────────────────
+ private void OnClosing(AppWindow sender, AppWindowClosingEventArgs args)
+ {
+ try
+ {
+ // 有正在进行的复制/移动时不静默退出,避免用户以为文件已搬完
+ var hasActiveJob = _services.Operations.Jobs.Any(j => !j.IsFinished);
+ if (hasActiveJob && !_settings.KeepWindowOpenDuringOperations)
+ {
+ args.Cancel = true;
+ return;
+ }
+
+ ViewModel.SaveOpenTabs();
+
+ // 索引持有卷句柄,退出时显式释放
+ foreach (var index in _services.Search.AllIndexes)
+ {
+ if (index is IDisposable disposable) disposable.Dispose();
+ }
+
+ _settings.WindowMaximized = AppWindow.Presenter is OverlappedPresenter { State: OverlappedPresenterState.Maximized };
+ if (!_settings.WindowMaximized)
+ {
+ _settings.WindowLeft = AppWindow.Position.X;
+ _settings.WindowTop = AppWindow.Position.Y;
+ _settings.WindowWidth = AppWindow.Size.Width;
+ _settings.WindowHeight = AppWindow.Size.Height;
+ }
+ _settings.Save();
+ }
+ catch
+ {
+ // 关闭时保存失败不应阻塞退出
+ }
+ }
+}
diff --git a/Models/FileEntry.cs b/Models/FileEntry.cs
new file mode 100644
index 0000000..9c08443
--- /dev/null
+++ b/Models/FileEntry.cs
@@ -0,0 +1,158 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using Microsoft.UI.Xaml.Media;
+
+namespace FluidExplorer.Models;
+
+/// 轻量文件系统条目:由快速枚举一次性填充(不产生额外系统调用)。
+public sealed class FileEntry
+{
+ public required string Name { get; init; }
+ public required string FullPath { get; init; }
+ public bool IsDirectory { get; init; }
+ public long Size { get; init; }
+ public DateTime ModifiedUtc { get; init; }
+ public DateTime CreatedUtc { get; init; }
+ public DateTime AccessedUtc { get; init; }
+ public FileAttributes Attributes { 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 string TypeKey => IsDirectory ? "folder" : (Extension.Length == 0 ? "file" : Extension.ToLowerInvariant());
+
+ public bool IsHidden => (Attributes & FileAttributes.Hidden) != 0;
+ public bool IsSystem => (Attributes & FileAttributes.System) != 0;
+ public bool IsReparsePoint => (Attributes & FileAttributes.ReparsePoint) != 0;
+ public bool IsReadOnly => (Attributes & FileAttributes.ReadOnly) != 0;
+ public bool IsOffline => (Attributes & FileAttributes.Offline) != 0;
+
+ public DateTime ModifiedLocal => ModifiedUtc.ToLocalTime();
+ public DateTime CreatedLocal => CreatedUtc.ToLocalTime();
+
+ /// Windows 资源管理器风格的尺寸文本(已按 1024 进制并为文件夹留空)。
+ public string SizeText => IsDirectory ? string.Empty : FormatSize(Size);
+
+ /// 类型描述:优先使用注册表里的友好类型名(惰性、带缓存)。
+ public string TypeText => IsDirectory ? "文件夹" : Services.Shell.TypeNameResolver.GetTypeName(Extension, IsDirectory);
+
+ public static string FormatSize(long bytes)
+ {
+ if (bytes < 0) return string.Empty;
+ if (bytes < 1024) return $"{bytes} 字节";
+ string[] units = ["KB", "MB", "GB", "TB", "PB"];
+ double v = bytes;
+ int u = -1;
+ do { v /= 1024.0; u++; } while (v >= 1024 && u < units.Length - 1);
+ return v >= 100 ? $"{v:0} {units[u]}" : v >= 10 ? $"{v:0.0} {units[u]}" : $"{v:0.00} {units[u]}";
+ }
+}
+
+/// 列表里的一行:可观察包装,缩略图/图标异步补齐,不阻塞滚动。
+public sealed class ExplorerItem : INotifyPropertyChanged
+{
+ private ImageSource? _icon;
+ private bool _isSelected;
+ private bool _isRenaming;
+ private string _renameText = string.Empty;
+ private string? _displayName;
+ private int _iconSize = 16;
+
+ public ExplorerItem(FileEntry entry) => Entry = entry;
+
+ public FileEntry Entry { get; }
+ public string Name => Entry.Name;
+
+ /// 界面显示名("显示文件扩展名"关闭时隐藏扩展名,与资源管理器一致)。
+ public string DisplayName
+ {
+ get => _displayName ?? Entry.Name;
+ set { if (_displayName != value) { _displayName = value; OnPropertyChanged(); } }
+ }
+
+ /// 是否为新建/重命名中的占位项(这些项要滚动到可见并进入编辑状态)。
+ public bool IsPending { get; set; }
+ public string FullPath => Entry.FullPath;
+ public bool IsDirectory => Entry.IsDirectory;
+ public long Size => Entry.Size;
+ public string SizeText => Entry.SizeText;
+ public string TypeText => Entry.TypeText;
+ public DateTime ModifiedLocal => Entry.ModifiedLocal;
+ public string ModifiedText => Entry.ModifiedLocal.ToString("yyyy/MM/dd HH:mm");
+ public string CreatedText => Entry.CreatedLocal.ToString("yyyy/MM/dd HH:mm");
+
+ /// 图标或缩略图(ImageSource 由 UI 线程创建后写入;先给几何图标占位,避免跳动)。
+ public ImageSource? Icon
+ {
+ get => _icon;
+ set { if (!ReferenceEquals(_icon, value)) { _icon = value; OnPropertyChanged(); } }
+ }
+
+ /// 是否已经替换为真实缩略图(用于淡入动画)。
+ public bool HasThumbnail { get; set; }
+
+ public bool IsSelected
+ {
+ get => _isSelected;
+ set
+ {
+ if (_isSelected == value) return;
+ _isSelected = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ShowCheckBox));
+ }
+ }
+
+ /// 复选框是否可见(对齐资源管理器:选中时显示,或用户开启了"始终显示项目复选框")。
+ public bool ShowCheckBox => _isSelected || AlwaysShowCheckBoxes;
+
+ /// 全局设置:始终显示项目复选框。
+ public static bool AlwaysShowCheckBoxes { get; set; }
+
+ /// 当前视图所需的图标像素尺寸(由窗格在切换视图方式时写入)。
+ public int IconSize
+ {
+ get => _iconSize;
+ set { if (_iconSize != value) { _iconSize = value; OnPropertyChanged(); } }
+ }
+
+ public bool IsRenaming
+ {
+ get => _isRenaming;
+ set { if (_isRenaming != value) { _isRenaming = value; OnPropertyChanged(); } }
+ }
+
+ public string RenameText
+ {
+ get => _renameText;
+ set { if (_renameText != value) { _renameText = value; OnPropertyChanged(); } }
+ }
+
+ public string Extension => Entry.Extension;
+ public bool IsHidden => Entry.IsHidden;
+ public bool IsReparsePoint => Entry.IsReparsePoint;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void OnPropertyChanged([CallerMemberName] string? name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
+
+/// 当前文件夹的内容集合。
+public sealed class FolderListing
+{
+ public required string Path { get; set; }
+ public ObservableCollection Items { get; } = [];
+ public bool IsLoading { get; set; }
+ public bool IsComplete { get; set; }
+ public string? Error { get; set; }
+ public int TotalCount => Items.Count;
+}
diff --git a/Models/SortSpec.cs b/Models/SortSpec.cs
new file mode 100644
index 0000000..a0d6d27
--- /dev/null
+++ b/Models/SortSpec.cs
@@ -0,0 +1,50 @@
+namespace FluidExplorer.Models;
+
+public enum SortColumn
+{
+ Name,
+ DateModified,
+ DateCreated,
+ Type,
+ Size,
+ Path,
+ Extension,
+ Attributes
+}
+
+public enum SortDirection
+{
+ Ascending,
+ Descending
+}
+
+public sealed record SortSpec(SortColumn Column, SortDirection Direction)
+{
+ public static SortSpec Default { get; } = new(SortColumn.Name, SortDirection.Ascending);
+ public SortSpec Toggle(SortColumn column)
+ => Column == column
+ ? this with { Direction = Direction == SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending }
+ : new SortSpec(column, SortDirection.Ascending);
+}
+
+/// 视图模式:对齐资源管理器(详情/列表/网格三档/内容)。
+public enum ViewMode
+{
+ ExtraLargeIcons,
+ LargeIcons,
+ MediumIcons,
+ SmallIcons,
+ List,
+ Details,
+ Tiles,
+ Content
+}
+
+public enum GroupBy
+{
+ None,
+ Name,
+ DateModified,
+ Type,
+ Size
+}
diff --git a/Navigation/NavigationLocation.cs b/Navigation/NavigationLocation.cs
new file mode 100644
index 0000000..ff8501e
--- /dev/null
+++ b/Navigation/NavigationLocation.cs
@@ -0,0 +1,85 @@
+namespace FluidExplorer.Navigation;
+
+public enum LocationKind
+{
+ Home,
+ Gallery,
+ QuickAccess,
+ ThisPc,
+ RecycleBin,
+ Network,
+ Drive,
+ Folder,
+ Search
+}
+
+/// 一个可导航的位置(侧边栏节点、面包屑、标签页都基于它)。
+public sealed record NavigationLocation(LocationKind Kind, string Path, string DisplayName, string Glyph)
+{
+ public static NavigationLocation Home { get; } = new(LocationKind.Home, Services.Shell.KnownFolders.HomeParsingName, "主页", "\uE80F");
+ public static NavigationLocation Gallery { get; } = new(LocationKind.Gallery, Services.Shell.KnownFolders.GalleryParsingName, "图库", "\uE91B");
+ public static NavigationLocation ThisPc { get; } = new(LocationKind.ThisPc, Services.Shell.KnownFolders.ThisPcParsingName, "此电脑", "\uE977");
+ public static NavigationLocation RecycleBin { get; } = new(LocationKind.RecycleBin, Services.Shell.KnownFolders.RecycleBinParsingName, "回收站", "\uE74D");
+ public static NavigationLocation Network { get; } = new(LocationKind.Network, Services.Shell.KnownFolders.NetworkParsingName, "网络", "\uE968");
+
+ public static NavigationLocation FromPath(string path, LocationKind kind = LocationKind.Folder)
+ {
+ var normalized = Services.FileSystem.PathHelper.NormalizeDisplay(path);
+ return new NavigationLocation(kind, normalized, Services.FileSystem.PathHelper.GetName(normalized), "\uE8B7");
+ }
+
+ public static NavigationLocation FromDrive(DriveItemInfo drive)
+ => new(LocationKind.Drive, drive.RootPath, drive.DisplayName, drive.Glyph);
+
+ /// 是否是外壳虚拟位置(不能用 System.IO 直接枚举,需要走 shell: 视图)。
+ public bool IsVirtual => Kind is LocationKind.Home or LocationKind.Gallery or LocationKind.ThisPc
+ or LocationKind.RecycleBin or LocationKind.Network or LocationKind.Search
+ || Path.StartsWith("::", StringComparison.Ordinal) || Path.StartsWith("shell:", StringComparison.OrdinalIgnoreCase);
+}
+
+public readonly record struct DriveItemInfo(string RootPath, string DisplayName, string Glyph);
+
+/// 前进/后退历史(资源管理器行为:新导航截断前进栈)。
+public sealed class NavigationHistory
+{
+ private readonly List _back = [];
+ private readonly List _forward = [];
+ private const int Capacity = 64;
+
+ public bool CanGoBack => _back.Count > 1;
+ public bool CanGoForward => _forward.Count > 0;
+ public NavigationLocation? Current => _back.Count > 0 ? _back[^1] : null;
+
+ public void Push(NavigationLocation location)
+ {
+ if (_back.Count > 0 && _back[^1] == location) return;
+ _back.Add(location);
+ if (_back.Count > Capacity) _back.RemoveAt(0);
+ _forward.Clear();
+ }
+
+ public NavigationLocation? Back()
+ {
+ if (!CanGoBack) return null;
+ var current = _back[^1];
+ _back.RemoveAt(_back.Count - 1);
+ _forward.Add(current);
+ return _back[^1];
+ }
+
+ public NavigationLocation? Forward()
+ {
+ if (_forward.Count == 0) return null;
+ var next = _forward[^1];
+ _forward.RemoveAt(_forward.Count - 1);
+ _back.Add(next);
+ return next;
+ }
+
+ public void Reset(NavigationLocation location)
+ {
+ _back.Clear();
+ _forward.Clear();
+ _back.Add(location);
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fad8dfa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,151 @@
+# Fluid 文件资源管理器(WinUI 3 重构版)
+
+用 **Windows App SDK / WinUI 3** 从零实现的 Windows 11 文件资源管理器替代品。
+视觉与布局对齐原版(标签栏在标题栏内、Mica、命令栏、面包屑地址栏、导航窗格、详情/图标视图、状态栏),
+但针对原版被吐槽最多的几个痛点做了结构性改造。
+
+> 只用 **Windows 11 原生那一套**:WinUI 3 自带控件(NavigationView 风格侧边栏用 TreeView、TabView、BreadcrumbBar、AutoSuggestBox、InfoBar、ContentDialog…)、
+> **Segoe Fluent Icons** 官方图标字体、**外壳原版文件图标**(`SHGetFileInfo` / `IShellItemImageFactory`,即资源管理器显示的同一批 imageres.dll 图标)、
+> 以及系统自带的 Mica 材质与 WinUI 原生动画。**没有引入任何第三方 UI/图标/动画库**。
+
+---
+
+## 1. 直接运行
+
+```powershell
+dotnet build E:\deepseek\FluidExplorer\FluidExplorer.csproj -c Debug
+E:\deepseek\FluidExplorer\bin\Debug\net8.0-windows10.0.26100.0\win-x64\FluidExplorer.exe
+```
+
+- 目标框架 `net8.0-windows10.0.26100.0`,x64,非打包(`WindowsPackageType=None`)+ **Windows App SDK 自包含**,
+ 因此不需要预装 Windows App SDK 运行时,双击 exe 即可。
+- 本机 NuGet 离线(`nuget.config` 里 ``):所有依赖已在全局包缓存中,可直接还原。
+ 依赖仅 `Microsoft.WindowsAppSDK 2.2.0` 与 `CommunityToolkit.Mvvm 8.4.2`(后者只用于减少 `INotifyPropertyChanged` 样板代码)。
+- 设置文件:`%LOCALAPPDATA%\FluidExplorer\settings.json`(写入失败时退回程序目录)。
+- 启动日志:程序目录下 `startup.log`(用于定位启动期异常)。
+
+---
+
+## 2. 针对原版痛点的改造
+
+| 原版痛点 | 本实现的做法 |
+|---|---|
+| **搜索慢/搜不到**(原版走 Windows Search 索引,往往等半天甚至永远"正在搜索") | Everything 式 **NTFS 索引**:`FSCTL_ENUM_USN_DATA` 直读 MFT 全集 + 原始 MFT 解析取真实大小/时间 + `FSCTL_READ_USN_JOURNAL` 增量监听;查询是内存并行扫描,**20 万条目下 0.3–9 ms**(见 §5)。没有索引时自动退化为可取消的实时扫描,并在界面上明确标注"实时扫描",绝不假装在搜 |
+| **搜索框必须回车、没有即时反馈** | 键入即搜(110 ms 防抖)+ 搜索框下拉即时建议 + 结果区实时显示"N 项结果 · X 毫秒 · 数据来源" |
+| **搬文件很麻烦**(开两个窗口来回拖) | ① **双窗格**(Ctrl+Shift+D,中间可拖动分隔条)② 跨窗格剪贴板 ③ 拖到文件夹行/侧边栏即可移动(按住 Ctrl 复制)④ "移动到/复制到"以外的批量操作统一进队列 |
+| **复制/移动卡死、无法暂停/取消** | 独立**文件操作引擎**:后台队列、**可暂停/继续/取消**、字节级进度 + 速度 + 剩余时间、失败重试(100/300/900 ms)、单文件失败不中断整批、长路径(`\\?\`)支持、同卷移动走 `File.Move`(1000 个文件 15 ms) |
+| **覆盖冲突弹系统模态框、还打断操作** | 应用内冲突对话框(保留两者/替换/跳过 + "对后续所有冲突应用"),作业在等待用户选择时**保持运行态、UI 完全不卡** |
+| **误操作无法挽回** | **Ctrl+Z 一步撤销**:移动/重命名搬回原位;删除默认进回收站并记录 `$I`→`$R` 映射,撤销即从回收站还原(不依赖系统弹窗) |
+| **打开大文件夹/网络盘就"无响应"** | 枚举走 `FileSystemEnumerable`(底层 NtQueryDirectoryFile 批量缓冲,一次拿回名称+属性+大小+时间),**分批回调**(首批立刻上屏)、全程可取消、错误只在 InfoBar 提示不阻塞;属性列的真类型名走注册表并缓存 |
+| **状态栏信息少** | 底部状态栏常驻:项目数 / 选中项数与合计体积 / 索引进度与条数 / 一键建索引 / 视图切换 / **操作队列入口** |
+| **深浅色与强调色不跟随** | 全部颜色取自 WinUI 内置主题资源(不写死任何色值),`ElementTheme.Default` + `MicaBackdrop` 跟随系统;标题栏按钮颜色随主题切换;设置里可手动覆盖为浅/深色 |
+| **动画要么没有要么过度** | 只用 WinUI 原生动画(列表项增删、悬停/选中反馈、TabView、对话框、Mica 过渡);设置里可一键关闭(内部用 `Timeline.AllowDependentAnimations` 总开关),不额外叠加自造动效 |
+
+其他对齐原版的细节:真实路径面包屑(此电脑 › 本地磁盘 › …)、侧边栏(主页/图库/快速访问/此电脑/网络/回收站,可展开、跟随当前路径高亮)、
+按文件夹记住视图方式与排序(写入 `settings.json`)、"名称"列用系统 `StrCmpLogicalW` 自然排序(文件2 排在 文件10 前)、文件夹恒排在文件前、
+显示/隐藏隐藏项与扩展名、**系统原版右键菜单**(外壳 `IContextMenu`,第三方扩展条目也在)。
+
+键盘:`Ctrl+T/W/Tab` 标签页、`Ctrl+Shift+D` 双窗格、`Alt+←/→/↑` 前进后退上级、`Backspace` 上级、`F5` 刷新、`F2` 重命名、
+`Delete`/`Shift+Delete` 删除/彻底删除、`Ctrl+X/C/V`、`Ctrl+Shift+C` 复制路径、`Ctrl+Shift+N` 新建文件夹、`Ctrl+Z` 撤销、`Ctrl+F` 聚焦搜索、`Ctrl+L` 编辑地址。
+
+---
+
+## 3. 代码结构
+
+```
+FluidExplorer/
+├─ App.xaml(.cs) 应用入口、启动阶段日志、全局异常兜底
+├─ MainWindow.xaml(.cs) 窗口外壳:Mica、扩展标题栏、标题栏按钮配色、快捷键、冲突对话框
+├─ Views/
+│ ├─ ShellView.xaml(.cs) 标签栏(位于标题栏区域内)+ 操作队列面板
+│ ├─ ExplorerTabView.xaml(.cs) 单/双窗格布局与分隔条
+│ ├─ ExplorerPaneView.xaml(.cs) 一个浏览窗格:命令栏 / 地址栏 / 导航窗格 / 内容区 / 状态栏
+│ └─ SettingsDialog.xaml(.cs) 设置
+├─ ViewModels/
+│ ├─ MainViewModel.cs 标签页、侧边栏、索引状态、作业快照、设置
+│ ├─ ExplorerTabViewModel.cs 标签页 = 1~2 个窗格
+│ ├─ ExplorerPaneViewModel.cs 导航状态机:枚举、排序、搜索、选择、文件操作、回收站、图库
+│ ├─ JobRowViewModel.cs UI 线程的作业快照(作业在后台线程更新,不能直接绑 UI)
+│ └─ SidebarNode.cs 侧边栏节点 / 面包屑段 / 搜索结果行
+├─ Models/ FileEntry / ExplorerItem / FolderListing / 排序与视图枚举
+├─ Navigation/ 位置模型(本机/图库/此电脑/回收站/驱动器/路径)+ 前进后退历史
+├─ Services/
+│ ├─ AppServices.cs 组合根
+│ ├─ AppSettings.cs 设置持久化(含按文件夹视图状态)
+│ ├─ FileSystem/ 快速枚举(NtQueryDirectoryFile 批量缓冲)+ 路径工具
+│ ├─ Search/ 查询语法解析、搜索门面、NTFS 索引(Usn/:MFT 解析、名字池、索引存储、通配符)
+│ ├─ Icons/ 外壳原版图标与缩略图(SHGetFileInfo / IShellItemImageFactory,LRU + 同键合并)
+│ ├─ Operations/ 文件操作队列引擎(复制/移动/删除/重命名/撤销/回收站定位)
+│ ├─ ItemVisuals/ 列表行的图标/缩略图按需加载(并发上限 + 去重)
+│ └─ Shell/ 外壳能力:已知文件夹、类型名、打开/属性/剪贴板、回收站视图、原版右键菜单
+├─ Themes/ Styles.xaml(尺寸/样式)、Glyphs.xaml(Segoe Fluent Icons 码点)
+└─ Helpers/ DispatcherQueue 异步封装、值转换器
+```
+
+设计要点:
+1. **接口先冻结再并行开发**:`IFileIndex` / `IIconService` / `IFileOperationService` / `IFileSystemService` 先定义,
+ 索引、图标、操作三个模块独立实现(互不依赖),最后由 `AppServices` 装配。
+2. **UI 线程零阻塞**:所有文件系统交互都是异步 + 可取消;集合更新经 `DispatcherQueue` 分批提交。
+3. **后台对象不直接绑 UI**:作业在后台线程更新,界面绑的是 250 ms 刷新的 UI 线程快照(`JobRowViewModel`)。
+4. **失败降级而不是崩溃**:假索引不可用时搜索自动退化;外壳取图失败返回 null 并回退到扩展名图标;启动异常写入 `startup.log`。
+
+---
+
+## 4. 搜索语法(对齐 Everything 习惯)
+
+```
+keyword 名称包含(大小写不敏感,多个词 = AND)
+"两个 词" 带空格的短语
+!keyword 排除
+*.json / pre* 通配符
+ext:log;txt 扩展名(可多值)
+size:>100mb 大小(kb/mb/gb,支持 > < =)
+dm:today / dm:7d 修改时间(today/yesterday/thisweek/thismonth/thisyear/Nd/Nh/Nw 或具体日期)
+dc:today 创建时间(需要索引提供创建时间,当前版本未启用)
+folder: / file: 只看文件夹 / 只看文件
+path:Windows 在完整路径中匹配
+```
+
+---
+
+## 5. 验证情况(都基于本机实测,不采信"应该能用")
+
+**实测通过**
+- 工程编译:`0 error`;XAML 全部编译为 xbf(含主题字典)。
+- 端到端启动:进程存活、窗口标题「文件资源管理器」、UI Automation 树确认——标签栏在标题栏区域内(系统按钮区已正确让位)、
+ 命令栏 12 个按钮、地址栏(后退/前进/上级/刷新 + 2 段面包屑 + 搜索框)、导航窗格 16 个节点、
+ 详情视图 4 个列头(名称/修改日期/类型/大小)、**真实列出 107 行文件**、状态栏与操作队列入口在位。
+- 搜索:通过 UI Automation 向搜索框写入关键字,界面返回结果行并显示「N 项结果 · X 毫秒 · 数据来源」。
+- 文件操作引擎(独立探针,62/62 断言 PASS):300 MB 复制的字节级进度;**暂停 1 秒增长 0 字节**;取消保留已完成部分;
+ 同卷移动 1000 文件 **15 ms**(零字节流量);`KeepBoth/Replace/Skip/Ask+ApplyToAll` 全部正确;
+ 308→312 字符长路径复制/移动成功;跨卷移动可撤销;永久删除不入撤销栈。
+- NTFS 索引(独立探针,走生产代码路径):20 万条目合成数据集 **77 字节/条**;
+ 查询 `*.json` 3.3 ms、`ext:log` 1.4 ms、`size:>100mb` 0.66 ms、`dm:today` 0.3 ms、`path:Windows` 9.3 ms;
+ 2000 条路径还原 1.66 ms;增量(创建/改名/删除)与墓碑正确;大小未知的条目不会被 `size:` 误判为 0 字节。
+ 过程中修掉一个会**静默丢 64% 结果**的扩容 bug(名字池被换新导致老记录名字失效)。
+- 图标:`.txt/.exe/目录/驱动器` 均取到系统原生图标(32×32 原生尺寸、真 alpha);`shell:RecycleBinFolder` 等外壳对象正常;
+ 图片缩略图 256×160 正常;1000 次扩展名取图 86 ms;300 次取图后 GDI/USER 句柄**零增长**。
+
+**已知限制(不隐瞒)**
+- **当前会话没有管理员权限**:`CreateFile(@"\\.\C:")` 被拒(`FSCTL_*` 返回 `ERROR_INVALID_FUNCTION`),
+ 因此 NTFS 索引在本机本次验证中走的是 `RequiresElevation` 分支;界面状态栏会提示并可一键「以管理员身份重启」。
+ 索引本身的正确性用同一份生产代码 + 合成数据集验证(见上)。**要拿到真实全盘索引,请以管理员身份运行一次。**
+- 非管理员降级模式下文件大小全部为"未知"(USN 记录本身不含 size,真实大小依赖原始 MFT 解析),此时 `size:` 过滤会偏宽松。
+- 拖放到**其他应用程序**、拖出标签新建窗口、网络邻居枚举为简化实现;图库依赖索引(无索引时给提示而非空列表)。
+- 缩略图/图标语义、字体字形只做了"码点在字体中存在"的校验(60/60 存在,不会出现方框),
+ 但**字形语义未经人眼确认**(本会话的模型不能读图),个别按钮图标若观感不佳可直接改 `Themes/Glyphs.xaml` 里的码点。
+
+---
+
+## 6. 复现验证
+
+```powershell
+# 编译
+dotnet build E:\deepseek\FluidExplorer\FluidExplorer.csproj -c Debug
+
+# 启动 + UI 自动化结构检查 + 截图(截图落在 E:\deepseek\artifacts\shell_window.png)
+powershell -NoProfile -ExecutionPolicy Bypass -File E:\deepseek\tools\smoke_test.ps1
+
+# 字形存在性校验(Segoe Fluent Icons)
+powershell -NoProfile -ExecutionPolicy Bypass -File E:\deepseek\tools\check_glyphs.ps1
+```
diff --git a/Services/AppServices.cs b/Services/AppServices.cs
new file mode 100644
index 0000000..29d4adf
--- /dev/null
+++ b/Services/AppServices.cs
@@ -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;
+
+/// 组合根:所有服务在这里创建、装配,并交给视图模型。
+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; }
+
+ /// 接入 Windows 外壳原版图标(imageres.dll / IShellItemImageFactory)。
+ private static IIconService CreateIconService(DispatcherQueue ui)
+ {
+ try
+ {
+ return new ShellIconService(ui);
+ }
+ catch
+ {
+ return new PlaceholderIconService();
+ }
+ }
+
+ /// 接入文件操作队列引擎。
+ private static IFileOperationService CreateOperationService()
+ {
+ try
+ {
+ return new FileOperationService();
+ }
+ catch
+ {
+ return new PlaceholderOperationService();
+ }
+ }
+
+ /// 接入 NTFS USN/MFT 索引(Everything 式极速搜索)。
+ public void WireSearchIndex()
+ {
+ Search.IndexFactory = volumeRoot =>
+ {
+ try
+ {
+ return new FluidExplorer.Services.Search.Usn.UsnVolumeIndex(volumeRoot);
+ }
+ catch
+ {
+ // 非 NTFS 卷等异常情况:该卷不建索引,搜索自动退化为实时扫描
+ return null;
+ }
+ };
+ }
+}
diff --git a/Services/AppSettings.cs b/Services/AppSettings.cs
new file mode 100644
index 0000000..a7b1642
--- /dev/null
+++ b/Services/AppSettings.cs
@@ -0,0 +1,153 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using FluidExplorer.Models;
+
+namespace FluidExplorer.Services;
+
+/// 外观模式:默认跟随系统(深浅色自动切换)。
+public enum AppThemeMode
+{
+ System,
+ Light,
+ Dark
+}
+
+public enum SearchScope
+{
+ /// 全盘(走 NTFS 索引,Everything 式,毫秒级)。
+ Global,
+ /// 仅当前文件夹及其子目录。
+ 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;
+}
+
+/// 用户设置(%LOCALAPPDATA%\FluidExplorer\settings.json),失败时退回程序目录。
+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 PinnedFolders { get; set; } = [];
+ public List RecentFolders { get; set; } = [];
+ public List OpenTabs { get; set; } = [];
+ public bool DualPane { get; set; }
+
+ // 搜索
+ public SearchScope SearchScope { get; set; } = SearchScope.Global;
+ public List 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; }
+
+ /// 按文件夹记住视图方式(对齐资源管理器的"按文件夹记住视图设置")。
+ public Dictionary 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(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 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
+}
diff --git a/Services/FileSystem/FastFileSystemService.cs b/Services/FileSystem/FastFileSystemService.cs
new file mode 100644
index 0000000..47e8d87
--- /dev/null
+++ b/Services/FileSystem/FastFileSystemService.cs
@@ -0,0 +1,223 @@
+using System.Diagnostics;
+using System.IO.Enumeration;
+using FluidExplorer.Models;
+
+namespace FluidExplorer.Services.FileSystem;
+
+///
+/// 资源管理器最核心的性能路径:目录枚举。
+/// 使用 .NET 的 FileSystemEnumerable(底层为 NtQueryDirectoryFile + 大缓冲批量返回),
+/// 一次调用即可拿到名称/属性/大小/时间,比 DirectoryInfo 逐文件查询快一个数量级。
+///
+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; }
+ }
+
+ ///
+ /// 分批异步枚举:首批(batchSize 条)会尽快回调,让界面立刻有内容;
+ /// 整个枚举可取消,不会阻塞调用线程。
+ ///
+ public async Task EnumerateAsync(
+ string path,
+ FolderListing listing,
+ Func, 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(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(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 Enumerate(string path, bool includeHidden = true)
+ {
+ var list = new List(256);
+ foreach (var e in EnumerateCore(path, CancellationToken.None))
+ {
+ if (!includeHidden && e.IsHidden) continue;
+ list.Add(e);
+ }
+ return list;
+ }
+
+ /// 核心枚举:单次系统调用序列,无逐文件 stat。
+ private static IEnumerable EnumerateCore(string path, CancellationToken cancellationToken)
+ {
+ var normalized = PathHelper.NormalizeForApi(path);
+ var enumerable = new FileSystemEnumerable(
+ 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;
+ }
+ }
+
+ /// 递归求文件夹大小:并行遍历 + 可取消,用于属性对话框与状态栏提示。
+ public Task GetDirectorySizeAsync(string path, CancellationToken cancellationToken)
+ => Task.Run(() =>
+ {
+ long total = 0;
+ var stack = new Stack();
+ 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);
+}
+
+/// 路径规范化统一入口(长路径、去掉尾部分隔符、避免重复分隔符)。
+public static class PathHelper
+{
+ public const string ExtendedPrefix = @"\\?\";
+ public const string ExtendedUncPrefix = @"\\?\UNC\";
+
+ /// 用于 Win32/文件系统 API 的路径(超长路径自动加 \\?\ 前缀)。
+ 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;
+ }
+
+ /// 去掉 \\?\ 前缀,用于显示。
+ 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;
+ }
+
+ /// 规范化显示路径:统一分隔符、去掉末尾分隔符(根目录除外)。
+ 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)..];
+ }
+}
diff --git a/Services/FileSystem/IFileSystemService.cs b/Services/FileSystem/IFileSystemService.cs
new file mode 100644
index 0000000..b0ed7df
--- /dev/null
+++ b/Services/FileSystem/IFileSystemService.cs
@@ -0,0 +1,30 @@
+using FluidExplorer.Models;
+
+namespace FluidExplorer.Services.FileSystem;
+
+///
+/// 快速目录枚举:内部使用 .NET 的 FileSystemEnumerable(NtQueryDirectoryFile 批量缓冲)
+/// 一次取回名称/属性/大小/时间,避免逐文件 Win32 调用。
+///
+public interface IFileSystemService
+{
+ ///
+ /// 异步枚举目录,分批回调(首批尽可能快,保证 UI 立刻有内容),
+ /// 整体可取消;无权限/网络超时不抛异常,通过 listing.Error 汇报。
+ ///
+ Task EnumerateAsync(
+ string path,
+ FolderListing listing,
+ Func, Task> onBatch,
+ int batchSize = 256,
+ CancellationToken cancellationToken = default);
+
+ /// 同步枚举(供后台索引/搜索回退用),返回全部条目。
+ IReadOnlyList Enumerate(string path, bool includeHidden = true);
+
+ bool DirectoryExists(string path);
+ bool FileExists(string path);
+
+ /// 计算文件夹大小(后台、可取消)。
+ Task GetDirectorySizeAsync(string path, CancellationToken cancellationToken);
+}
diff --git a/Services/Icons/IIconService.cs b/Services/Icons/IIconService.cs
new file mode 100644
index 0000000..8e80cbc
--- /dev/null
+++ b/Services/Icons/IIconService.cs
@@ -0,0 +1,31 @@
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml.Media;
+
+namespace FluidExplorer.Services.Icons;
+
+///
+/// 图标/缩略图服务:全部来自 Windows 外壳(imageres.dll 等系统图标库、IShellItemImageFactory),
+/// 也就是资源管理器本身显示的那批原版图标,不做自制图标。
+///
+public interface IIconService
+{
+ /// 系统小图标(16/32/48px 的多尺寸 HICON),用于列表与侧边栏。目录、驱动器、特殊文件夹同样走外壳。
+ Task GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken);
+
+ /// 大缩略图(SIIGBF_THUMBNAILONLY + 图标回退),用于网格/磁贴视图。失败返回 null。
+ Task GetThumbnailAsync(string path, int size, CancellationToken cancellationToken);
+
+ /// 按扩展名取通用图标(同类型文件共用一个位图,命中率极高)。
+ Task GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken);
+
+ /// 取某个已知外壳文件夹(如 "shell:RecycleBinFolder"、"::{20D04FE0-3AEA-1069-A2D8-08002B30309D}")的图标。
+ ImageSource? GetSpecialFolderIcon(string parsingName, int size);
+
+ void ClearCache();
+}
+
+/// 图标服务需要在 UI 线程创建 ImageSource,构造时注入 DispatcherQueue。
+public interface IIconServiceHost
+{
+ DispatcherQueue DispatcherQueue { get; }
+}
diff --git a/Services/Icons/ShellIconService.cs b/Services/Icons/ShellIconService.cs
new file mode 100644
index 0000000..10faccd
--- /dev/null
+++ b/Services/Icons/ShellIconService.cs
@@ -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;
+
+///
+/// 系统原版图标 / 缩略图服务。
+///
+/// 设计要点:
+/// 1) 取图全部在外壳与 GDI 侧完成(可后台线程),只有 的创建被派回 UI 线程;
+/// SoftwareBitmapSource 是 UI 线程亲和对象,后台线程碰它会直接崩。
+/// 2) 结果带 LRU 缓存 + 同键请求合并(Lazy<Task>),列表快速滚动时不会重复解码同一张图。
+/// 3) 任何未预期异常都会被吞成 null 并写 Debug 输出,服务层绝不把异常抛进 UI 线程。
+///
+public sealed class ShellIconService : IIconService
+{
+ private const int IconCacheCapacity = 512;
+ private const int ThumbnailCacheCapacity = 256;
+ private const int MaxConcurrentNativeWork = 4;
+
+ /// 缩略图 E_PENDING 重试参数。
+ private const int ThumbnailRetryCount = 3;
+
+ /// 缩略图 E_PENDING 重试间隔(毫秒)。
+ private const int ThumbnailRetryDelayMs = 200;
+
+ /// UI 调度器;极端情况下可能为 null(此时退化为在调用线程创建 ImageSource)。
+ private readonly DispatcherQueue? _uiDispatcher;
+ private readonly Func _scaleProvider;
+
+ ///
+ /// 图标缓存:值类型是 。
+ /// 缓存"共享的 Task"而不是最终 ImageSource,才能在解码未完成时就完成同键请求合并;
+ /// 这也是 Lazy<Task> 模式的落点(LruCache 内层用 Lazy 保证工厂只跑一次)。
+ ///
+ private readonly LruCache> _iconCache = new(IconCacheCapacity);
+
+ /// 缩略图缓存,容量更小(缩略图位图大得多,全部是 256px 级别的位图)。
+ private readonly LruCache> _thumbnailCache = new(ThumbnailCacheCapacity);
+
+ /// 把纯 GDI 取图/解码节流在 4 路,避免整屏滚动时把 CPU 打满。
+ private readonly SemaphoreSlim _nativeThrottle = new(MaxConcurrentNativeWork, MaxConcurrentNativeWork);
+
+ /// 默认的 DPI 缩放(rasterizationScale 为 null 时按 1.0 处理)。
+ private static readonly Func DefaultScale = static () => 1.0;
+
+ ///
+ /// 构造。签名固定为 (DispatcherQueue, Func<double>?),见 Services/AppServices.cs 的调用。
+ ///
+ ///
+ /// 构造函数**不抛异常**:uiDispatcher 为 null 时回退到当前线程的 DispatcherQueue,
+ /// 再拿不到就退化为"直接在调用线程创建 ImageSource"。
+ /// 宁可降级也不要让 App 启动阶段直接崩掉。
+ ///
+ public ShellIconService(DispatcherQueue uiDispatcher, Func? rasterizationScale = null)
+ {
+ _uiDispatcher = uiDispatcher ?? TryGetCurrentDispatcher();
+ _scaleProvider = rasterizationScale ?? DefaultScale;
+ }
+
+ /// 尽力拿到当前线程的 DispatcherQueue;拿不到返回 null(构造期不抛异常)。
+ private static DispatcherQueue? TryGetCurrentDispatcher()
+ {
+ try
+ {
+ return DispatcherQueue.GetForCurrentThread();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[ShellIconService] GetForCurrentThread 失败: {ex.Message}");
+ return null;
+ }
+ }
+
+ /// 当前构造时注入的 UI 调度器(ViewModel 可用它确认自己在哪个线程上调用)。
+ public DispatcherQueue? UiDispatcher => _uiDispatcher;
+
+ // ─────────────────────────────────────────────────────────────
+ // IIconService
+ // ─────────────────────────────────────────────────────────────
+
+ ///
+ public Task GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(path)) return Task.FromResult(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(null);
+ }
+ }
+
+ ///
+ public Task GetThumbnailAsync(string path, int size, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(path)) return Task.FromResult(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(null);
+ }
+ }
+
+ ///
+ public Task GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken)
+ {
+ try
+ {
+ string ext = NormalizeExtension(extension);
+ if (ext.Length == 0) return Task.FromResult(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(null);
+ }
+ }
+
+ ///
+ ///
+ /// 注意:本方法是同步签名(见 IIconService)。它只应在 UI 线程调用,内部会同步等待取图完成,
+ /// 因此仅适合启动阶段取少量侧边栏图标,不要在滚动/渲染路径里按行调用。
+ ///
+ 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;
+ }
+ }
+
+ ///
+ public void ClearCache()
+ {
+ try
+ {
+ _iconCache.Clear();
+ _thumbnailCache.Clear();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[ShellIconService] ClearCache 失败: {ex}");
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // 取图主流程(全部在后台线程执行)
+ // ─────────────────────────────────────────────────────────────
+
+ /// 文件 / 目录 / 驱动器 / 已知外壳对象的图标。
+ private Task 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();
+ }
+ });
+ }
+
+ /// 扩展名通用图标:SHGFI_USEFILEATTRIBUTES + FILE_ATTRIBUTE_NORMAL,不访问磁盘。
+ private Task 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();
+ }
+ });
+ }
+
+ ///
+ /// 缩略图:SHCreateItemFromParsingName + IShellItemImageFactory.GetImage。
+ /// 失败一律返回 null(不抛异常);E_PENDING 表示外壳正在后台解码,等 200ms 重试,最多 3 次。
+ ///
+ private Task 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 细节
+ // ─────────────────────────────────────────────────────────────
+
+ /// SHGetFileInfoW 路径:目录走真实路径(保角标/个性化图标),文件走属性推断。
+ 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);
+ }
+
+ ///
+ /// SHGetFileInfoW 核心:先拿系统图像列表索引 → 按 size 选 SHIL 取对应尺寸 HICON;
+ /// 拿不到索引就退回 SHGetFileInfoW 直接给的 HICON(32x32)。
+ ///
+ 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);
+ }
+ }
+
+ /// shell: / ::{CLSID} 解析名的图标:SHParseDisplayName → PIDL → 系统图像列表。
+ 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);
+ }
+ }
+
+ /// 带 "::{CLSID}" 前缀但不带 shell: 前缀的解析名。
+ private BgraBuffer? LoadIconFromParsingName(string parsingName, int logicalSize)
+ => LoadIconFromShellParsingName(parsingName, logicalSize);
+
+ /// IShellItemImageFactory + SIIGBF_ICONONLY:大尺寸文件/扩展名的高清原版图标。
+ 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 线程的一段)
+ // ─────────────────────────────────────────────────────────────
+
+ ///
+ /// BGRA 像素 → 。
+ /// SoftwareBitmap 可在任意线程构造,但 SoftwareBitmapSource 必须在 UI 线程创建并 SetBitmapAsync,
+ /// 因此这里统一通过 派回 UI 线程。
+ ///
+ private Task 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;
+ });
+ }
+
+ ///
+ /// 把一段"必须跑在 UI 线程"的工作派回去执行,用 TaskCompletionSource 桥接。
+ /// 如果调用方已经在 UI 线程上,则直接同步执行,省掉一次调度往返。
+ ///
+ private Task RunOnUiThreadAsync(Func> 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(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(null);
+ }
+
+ if (!enqueued)
+ {
+ Debug.WriteLine("[ShellIconService] TryEnqueue 返回 false(消息循环已停止)");
+ return Task.FromResult(null);
+ }
+
+ return tcs.Task;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // 缓存 / 并发 / 异常兜底
+ // ─────────────────────────────────────────────────────────────
+
+ ///
+ /// 同键请求合并 + LRU 缓存。
+ /// Lazy<Task> 保证同一个键只会解码一次,其余调用方 await 同一个 Task(快速滚动不会重复解码)。
+ ///
+ private static Task GetOrAddAsync(
+ LruCache> cache,
+ string key,
+ Func> factory,
+ CancellationToken ct)
+ {
+ // 缓存命中时必须尊重取消:已取消就直接返回 null
+ if (cache.TryGet(key, out var cachedTask))
+ {
+ return ct.IsCancellationRequested ? Task.FromResult(null) : cachedTask;
+ }
+
+ // 注意 T 是 Task:Lazy 的工厂返回的就是这个共享 Task
+ var lazy = cache.GetOrCreate(
+ key,
+ _ => new Lazy>(
+ () => factory(CancellationToken.None),
+ LazyThreadSafetyMode.ExecutionAndPublication));
+
+ return AwaitLazyAsync(lazy, ct);
+ }
+
+ /// 等待共享的 Lazy<Task>,但让"本次调用"的取消能立刻返回 null(不打断别人共享的那次解码)。
+ private static async Task AwaitLazyAsync(Lazy> lazy, CancellationToken ct)
+ {
+ Task 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);
+ }
+
+ ///
+ /// await 共享 Task 的最后一层兜底。
+ /// 正常情况这里不会抛(各 Load*CoreAsync 都套了 RunSafeAsync),
+ /// 但共享 Task 一旦带异常,所有同键调用方都会中招,所以在出口再兜一次,确保公开 API 永不外抛。
+ ///
+ private static async Task AwaitSharedTaskAsync(Task task)
+ {
+ try
+ {
+ return await task.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[ShellIconService] 共享取图任务异常: {ex}");
+ return null;
+ }
+ }
+
+ ///
+ /// 服务层统一兜底:取消 → null;其它任何异常(含 COMException / SEHException / 未知)记 Debug 后返回 null。
+ /// 这里绝不让异常逃出去把 UI 线程打崩。
+ ///
+ private static async Task RunSafeAsync(Func> work)
+ {
+ try
+ {
+ return await work().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // 取消是正常路径(列表滚过去了),静默返回 null
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[ShellIconService] 取图失败: {ex}");
+ return null;
+ }
+ }
+
+ /// 逻辑尺寸归一到合法范围(16..1024)。
+ private static int NormalizeSize(int size)
+ {
+ if (size <= 0) return 32;
+ if (size < 16) return 16;
+ if (size > 1024) return 1024;
+ return size;
+ }
+
+ /// 归一化扩展名:去掉前导点、转小写;空则返回空串。
+ 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();
+ }
+
+ /// 按显示尺寸 × DPI 缩放算出实际渲染像素尺寸(保证高 DPI 不糊)。
+ 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;
+ }
+
+ ///
+ /// 按显示尺寸挑系统图像列表,保证拿到的是「原生该尺寸」的图标,而不是放大出来的。
+ /// 实测各列表的 HICON 原生尺寸:SHIL_LARGE(0)=32、SHIL_SMALL(1)=16、SHIL_EXTRALARGE(2)=48、SHIL_JUMBO(4)=256。
+ ///
+ 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;
+
+ /// 判断是否为已知外壳解析名(shell: 或 ::{CLSID})。
+ private static bool IsSpecialParsingName(string path)
+ => ShellParsingName.IsShellPrefix(path) || path.StartsWith("::", StringComparison.Ordinal);
+}
+
+///
+/// 极简 LRU 缓存:ConcurrentDictionary 负责高并发读取,LinkedList 记录使用顺序,
+/// 超出容量后从链表尾部淘汰最少使用的项。
+///
+/// 缓存值类型(本工程里是 ImageSource?)。
+internal sealed class LruCache
+{
+ private readonly int _capacity;
+ private readonly ConcurrentDictionary _map = new(StringComparer.OrdinalIgnoreCase);
+ private readonly LinkedList _order = new();
+ private readonly object _orderGate = new();
+
+ internal LruCache(int capacity)
+ {
+ _capacity = capacity > 0 ? capacity : 1;
+ }
+
+ private sealed class Entry
+ {
+ internal Entry(Lazy value) => Value = value;
+
+ internal Lazy Value { get; }
+
+ internal LinkedListNode? Node { get; set; }
+ }
+
+ /// 取缓存值(命中即刷新使用顺序)。
+ 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;
+ }
+
+ ///
+ /// 取或创建。注意这里返回的是 Lazy<T> 本身:同一键的并发调用方会拿到同一个实例,
+ /// 因此 T 为 Task 时天然实现了"同键请求合并"。
+ ///
+ internal Lazy GetOrCreate(string key, Func> 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();
+ }
+ }
+
+ /// 把命中的键移到链表头部(最近使用)。
+ 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);
+ }
+ }
+
+ /// 超出容量时从尾部淘汰(调用方已持有 _orderGate)。
+ 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;
+ }
+ }
+ }
+}
diff --git a/Services/Icons/ShellNative.cs b/Services/Icons/ShellNative.cs
new file mode 100644
index 0000000..ed60ff6
--- /dev/null
+++ b/Services/Icons/ShellNative.cs
@@ -0,0 +1,1151 @@
+using System.Runtime.InteropServices;
+
+namespace FluidExplorer.Services.Icons;
+
+///
+/// Windows 外壳 / GDI 的原生互操作层:只放 P/Invoke、COM 接口、常量与结构体,
+/// 不含任何 WinUI 依赖,因此可以被独立控制台探针直接链接复用。
+///
+///
+/// 本文件遵守「只用 Windows 11 原版组件」原则:所有图标都来自 imageres.dll / shell32.dll /
+/// 各应用自己的图标资源,经 SHGetFileInfoW、SHGetImageList、IShellItemImageFactory 取回,
+/// 不做任何自制或矢量化替代。
+///
+#if ICONPROBE
+// 独立控制台探针(E:\deepseek\_icon_probe)链接本文件时定义 ICONPROBE,
+// 以便直接驱动内部的 Win32/COM 步骤做验证;主工程编译时保持 internal。
+public static class ShellNative
+#else
+internal static class ShellNative
+#endif
+{
+ // ─────────────────────────────────────────────────────────────
+ // 常量
+ // ─────────────────────────────────────────────────────────────
+
+ /// SHGetFileInfo 标志:取图标句柄。
+ internal const uint SHGFI_ICON = 0x000000100;
+
+ /// SHGetFileInfo 标志:取大图标(默认,值为 0)。
+ internal const uint SHGFI_LARGEICON = 0x000000000;
+
+ /// SHGetFileInfo 标志:取小图标。
+ internal const uint SHGFI_SMALLICON = 0x000000001;
+
+ /// SHGetFileInfo 标志:取系统图像列表中的索引(配合 SHGetImageList 拿任意尺寸)。
+ internal const uint SHGFI_SYSICONINDEX = 0x00004000;
+
+ /// SHGetFileInfo 标志:不访问磁盘,用 dwFileAttributes 直接推断图标(扩展名通用图标走这条)。
+ internal const uint SHGFI_USEFILEATTRIBUTES = 0x000000010;
+
+ /// SHGetFileInfo 标志:取打开状态的图标(文件夹张开形态)。
+ internal const uint SHGFI_OPENICON = 0x000000002;
+
+ /// SHGetFileInfo 标志:pszPath 传的是 ITEMIDLIST 指针(shell: 解析名走这条)。
+ internal const uint SHGFI_PIDL = 0x000000008;
+
+ ///
+ /// 系统图像列表尺寸标识:16x16(资源管理器列表视图的小图标)。
+ /// 实测(_icon_probe):kind=1 → ImageList_GetIconSize 返回 16x16。
+ ///
+ internal const int SHIL_SMALL = 1;
+
+ ///
+ /// 系统图像列表尺寸标识:32x32(资源管理器详细信息的标准大图标)。
+ /// ⚠ 实测值是 0 而不是 1:Windows SDK 里 SHIL_LARGE=0x0、SHIL_SMALL=0x1,
+ /// kind=0 → 32x32、kind=1 → 16x16。写反会导致 32px 图标实际拿到 16x16 再被放大(发虚)。
+ ///
+ internal const int SHIL_LARGE = 0;
+
+ /// 系统图像列表尺寸标识:48x48(超大图标,实测 kind=2 → 48x48)。
+ internal const int SHIL_EXTRALARGE = 2;
+
+ /// 系统图像列表尺寸标识:16x16(同 SHIL_SMALL,历史别名;实测 kind=3 → 16x16)。
+ internal const int SHIL_SYSSMALL = 3;
+
+ /// 系统图像列表尺寸标识:256x256(Windows 11 高清图标,imageres.dll 里的原版 256px 资源;实测 kind=4)。
+ internal const int SHIL_JUMBO = 4;
+
+ /// IShellItemImageFactory 标志:等比缩放到请求尺寸(值为 0)。
+ internal const int SIIGBF_RESIZETOFIT = 0x000;
+
+ /// IShellItemImageFactory 标志:允许返回比请求更大的位图。
+ internal const int SIIGBF_BIGGERSIZEOK = 0x001;
+
+ /// IShellItemImageFactory 标志:只返回图标,绝不返回缩略图。
+ internal const int SIIGBF_ICONONLY = 0x004;
+
+ /// IShellItemImageFactory 标志:只返回缩略图,没有就失败(不做图标回退)。
+ internal const int SIIGBF_THUMBNAILONLY = 0x008;
+
+ /// IShellItemImageFactory 标志:只在内存/本地缓存里找,不触发网络或慢速解码。
+ internal const int SIIGBF_MEMORYONLY = 0x100;
+
+ /// 扩展名通用图标用的文件属性:普通文件(配合 SHGFI_USEFILEATTRIBUTES 不碰磁盘)。
+ internal const uint FILE_ATTRIBUTE_NORMAL = 0x00000080;
+
+ /// 扩展名通用图标用的文件属性:目录。
+ internal const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
+
+ /// E_PENDING:外壳正在后台解码,稍后重试即可(缩略图首次生成时常见)。
+ internal const int E_PENDING = unchecked((int)0x8000000A);
+
+ /// DrawIconEx 标志:按图标自身 alpha / 掩码正常绘制。
+ internal const uint DI_NORMAL = 0x0003;
+
+ /// GetSystemMetrics:标准大图标宽度。
+ internal const int SM_CXICON = 11;
+
+ /// GetSystemMetrics:标准大图标高度。
+ internal const int SM_CYICON = 12;
+
+ /// BITMAPINFOHEADER 的 BI_RGB。
+ internal const uint BI_RGB = 0;
+
+ /// DIB_RGB_COLORS。
+ internal const uint DIB_RGB_COLORS = 0;
+
+ // ─────────────────────────────────────────────────────────────
+ // 结构体
+ // ─────────────────────────────────────────────────────────────
+
+ /// SHGetFileInfoW 的输出结构(SHFILEINFOW)。
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ internal struct SHFILEINFOW
+ {
+ 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;
+ }
+
+ /// 尺寸参数(SIZE / Windows.Graphics.SizeInt32 的原生形态)。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct SIZE
+ {
+ public int cx;
+ public int cy;
+
+ public SIZE(int cx, int cy)
+ {
+ this.cx = cx;
+ this.cy = cy;
+ }
+ }
+
+ /// GDI BITMAP 结构(GetObject 输出,用于确认位图尺寸/位深)。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct BITMAP
+ {
+ public int bmType;
+ public int bmWidth;
+ public int bmHeight;
+ public int bmWidthBytes;
+ public ushort bmPlanes;
+ public ushort bmBitsPixel;
+ public IntPtr bmBits;
+ }
+
+ /// BITMAPINFOHEADER。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct BITMAPINFOHEADER
+ {
+ public uint biSize;
+ public int biWidth;
+ public int biHeight;
+ public ushort biPlanes;
+ public ushort biBitCount;
+ public uint biCompression;
+ public uint biSizeImage;
+ public int biXPelsPerMeter;
+ public int biYPelsPerMeter;
+ public uint biClrUsed;
+ public uint biClrImportant;
+ }
+
+ /// BITMAPINFO(此处只用 32bpp BI_RGB,无调色板)。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct BITMAPINFO
+ {
+ public BITMAPINFOHEADER bmiHeader;
+ public uint bmiColors;
+ }
+
+ /// GetIconInfo 的输出:HICON 拆出的掩码位图与颜色位图(必须 DeleteObject 释放)。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct ICONINFO
+ {
+ [MarshalAs(UnmanagedType.Bool)]
+ public bool fIcon;
+
+ public int xHotspot;
+ public int yHotspot;
+ public IntPtr hbmMask;
+ public IntPtr hbmColor;
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // P/Invoke
+ // ─────────────────────────────────────────────────────────────
+
+ ///
+ /// SHGetFileInfoW:按真实文件系统路径取外壳信息(会访问该路径,路径可带个性化图标叠加,如 OneDrive/共享角标)。
+ /// 这里显式写 EntryPoint,因为下面还有一个同签名的 PIDL 重载,C# 端必须用不同方法名区分。
+ ///
+ [DllImport("shell32.dll", EntryPoint = "SHGetFileInfoW", CharSet = CharSet.Unicode, SetLastError = false)]
+ internal static extern IntPtr SHGetFileInfoByPath(
+ string pszPath,
+ uint dwFileAttributes,
+ ref SHFILEINFOW psfi,
+ uint cbFileInfo,
+ uint uFlags);
+
+ /// SHGetFileInfoW(PIDL 版):传 ITEMIDLIST,用于 shell: 解析名(回收站、此电脑等)。
+ [DllImport("shell32.dll", EntryPoint = "SHGetFileInfoW", CharSet = CharSet.Unicode, SetLastError = false)]
+ internal static extern IntPtr SHGetFileInfoByPidl(
+ IntPtr pidl,
+ uint dwFileAttributes,
+ ref SHFILEINFOW psfi,
+ uint cbFileInfo,
+ uint uFlags);
+
+ ///
+ /// SHGetImageList:取系统图像列表(小/大/超大/巨幅)。
+ ///
+ ///
+ /// 【重要实现说明 · 实测踩坑记录】
+ /// SHGetImageList 返回的接口指针**不能**用 [ComImport] 的 IImageList 走 RCW 调用:
+ /// 实测(见 _icon_probe 验证)该对象的所谓 "vtable" 槽位里并不是函数指针
+ /// (读出来是 0x100000004 / 0x20021 这类数据),因此按槽位序号调用会直接
+ /// AccessViolationException,或在 GetImageCount 上返回垃圾值(16777215)。
+ /// Shell 文档保证该返回值同时就是一个 HIMAGELIST,所以这里按 HIMAGELIST 处理,
+ /// 用 comctl32 的 ImageList_GetIcon / ImageList_GetIconSize 取值——实测完全正常。
+ /// 注意:C# 方法名与导出名不同,必须显式写 EntryPoint,否则会 EntryPointNotFoundException。
+ ///
+ [DllImport("shell32.dll", EntryPoint = "SHGetImageList", PreserveSig = true)]
+ internal static extern int SHGetImageListRaw(int iImageList, ref Guid riid, out IntPtr ppvObj);
+
+ /// comctl32 图像列表函数:按索引取 HICON(调用方负责 DestroyIcon)。ILD_TRANSPARENT = 1。
+ [DllImport("comctl32.dll", SetLastError = true)]
+ internal static extern IntPtr ImageList_GetIcon(IntPtr himl, int i, uint flags);
+
+ /// comctl32 图像列表函数:取图像列表的图标尺寸。
+ [DllImport("comctl32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool ImageList_GetIconSize(IntPtr himl, ref int cx, ref int cy);
+
+ /// comctl32 图像列表函数:取图像数量(用于诊断/兜底校验索引是否越界)。
+ [DllImport("comctl32.dll", SetLastError = true)]
+ internal static extern int ImageList_GetImageCount(IntPtr himl);
+
+ /// SHParseDisplayName:把 "shell:RecycleBinFolder"、"{CLSID}" 之类解析成 PIDL(调用方负责 CoTaskMemFree)。
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
+ internal static extern int SHParseDisplayName(
+ string pszName,
+ IntPtr pbc,
+ out IntPtr ppidl,
+ uint sfgaoIn,
+ out uint psfgaoOut);
+
+ /// SHCreateItemFromParsingName:按路径创建 IShellItem(缩略图路径的入口)。
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
+ internal static extern int SHCreateItemFromParsingName(
+ string pszPath,
+ IntPtr pbc,
+ ref Guid riid,
+ [MarshalAs(UnmanagedType.Interface)] out IShellItemImageFactory ppv);
+
+ /// DestroyIcon:释放 SHGetFileInfo / GetIcon 返回的 HICON。
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool DestroyIcon(IntPtr hIcon);
+
+ /// GetIconInfo:HICON → 掩码位图 + 颜色位图(两个 HBITMAP 都要 DeleteObject)。
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo);
+
+ /// DrawIconEx:把 HICON 画进 DC(用于把图标栅格化成 32bpp BGRA)。
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool DrawIconEx(
+ IntPtr hdc,
+ int xLeft,
+ int yTop,
+ IntPtr hIcon,
+ int cxWidth,
+ int cyWidth,
+ uint istepIfAniCur,
+ IntPtr hbrFlickerFreeDraw,
+ uint diFlags);
+
+ /// GetSystemMetrics:取标准图标尺寸等系统度量。
+ [DllImport("user32.dll")]
+ internal static extern int GetSystemMetrics(int nIndex);
+
+ /// CreateCompatibleDC:创建内存 DC。
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern IntPtr CreateCompatibleDC(IntPtr hdc);
+
+ /// DeleteDC:释放内存 DC。
+ [DllImport("gdi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool DeleteDC(IntPtr hdc);
+
+ /// CreateDIBSection:创建 32bpp 自上而下 DIB,直接拿到 BGRA 像素指针。
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern IntPtr CreateDIBSection(
+ IntPtr hdc,
+ ref BITMAPINFO pbmi,
+ uint usage,
+ out IntPtr ppvBits,
+ IntPtr hSection,
+ uint offset);
+
+ /// CreateCompatibleBitmap:创建与 DC 兼容的位图(缩略图 HBITMAP 转像素时用)。
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int width, int height);
+
+ /// SelectObject:把 GDI 对象选入 DC,返回旧对象(必须还原)。
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern IntPtr SelectObject(IntPtr hdc, IntPtr hObject);
+
+ /// DeleteObject:释放 HBITMAP / DIB 等 GDI 对象。
+ [DllImport("gdi32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool DeleteObject(IntPtr hObject);
+
+ /// GetObjectW:读取 HBITMAP 的尺寸与位深。
+ [DllImport("gdi32.dll", EntryPoint = "GetObjectW", CharSet = CharSet.Unicode, SetLastError = true)]
+ internal static extern int GetObjectBitmap(IntPtr hgdiobj, int cbBuffer, ref BITMAP lpvObject);
+
+ /// GetDIBits:把 HBITMAP 的像素读成指定格式的 DIB(这里固定 32bpp BGRA)。
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern int GetDIBits(
+ IntPtr hdc,
+ IntPtr hbm,
+ uint start,
+ uint cLines,
+ IntPtr lpvBits,
+ ref BITMAPINFO lpbmi,
+ uint usage);
+
+ /// CoTaskMemFree:释放 SHParseDisplayName 返回的 PIDL。
+ [DllImport("ole32.dll")]
+ internal static extern void CoTaskMemFree(IntPtr pv);
+
+ // ─────────────────────────────────────────────────────────────
+ // COM:图像列表与外壳条目
+ // ─────────────────────────────────────────────────────────────
+
+ ///
+ /// IImageList:完整的 vtable 声明,作为外壳图像列表接口的参考定义保留。
+ ///
+ ///
+ /// ⚠ 不要用这个接口去调用 SHGetImageList 返回的对象(RCW 调用会 AccessViolation)。
+ /// 原因与正确做法见 的注释。
+ /// 这里保留完整声明是为了:1) 提供 IID(SHGetImageListRaw 需要传 REFIID);
+ /// 2) 当外层需要 IImageList 的其它能力(如 GetImageInfo)时,有准确的槽位定义可查。
+ /// 由于使用 [ComImport] 映射 vtable,中间槽位必须按顺序原样占位,否则偏移错位会取到错误的函数。
+ ///
+ [ComImport]
+ [Guid("46EB5926-582E-4017-9FDF-E8998DAA0950")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ internal interface IImageList
+ {
+ // ── IUnknown 占位(3 槽) ──
+
+ [PreserveSig]
+ int QueryInterface(ref Guid riid, out IntPtr ppvObject);
+
+ [PreserveSig]
+ IntPtr AddRef();
+
+ [PreserveSig]
+ IntPtr Release();
+
+ // ── vtable 槽位 3.. ──
+
+ [PreserveSig]
+ int Add(IntPtr hbmImage, IntPtr hbmMask, out int pi);
+
+ [PreserveSig]
+ int ReplaceIcon(int i, IntPtr hicon, out int pi);
+
+ [PreserveSig]
+ int SetOverlayImage(int iImage, int iOverlay);
+
+ [PreserveSig]
+ int Replace(int i, IntPtr hbmImage, IntPtr hbmMask);
+
+ [PreserveSig]
+ int AddMasked(IntPtr hbmImage, int crMask, out int pi);
+
+ [PreserveSig]
+ int Draw(IntPtr pimldp);
+
+ [PreserveSig]
+ int Remove(int i);
+
+ [PreserveSig]
+ int GetIcon(int i, int flags, out IntPtr picon);
+
+ [PreserveSig]
+ int GetImageInfo(int i, out IMAGEINFO pImageInfo);
+
+ [PreserveSig]
+ int Copy(int iDst, IImageList punkSrc, int iSrc, int uFlags);
+
+ [PreserveSig]
+ int Merge(int i1, IImageList punk2, int i2, int dx, int dy, ref Guid riid, out IntPtr ppv);
+
+ [PreserveSig]
+ int Clone(ref Guid riid, out IntPtr ppv);
+
+ [PreserveSig]
+ int GetImageRect(int i, out RECT prc);
+
+ [PreserveSig]
+ int GetIconSize(out int cx, out int cy);
+
+ [PreserveSig]
+ int SetIconSize(int cx, int cy);
+
+ [PreserveSig]
+ int GetImageCount(out int pi);
+
+ [PreserveSig]
+ int SetImageCount(int uNewCount);
+
+ [PreserveSig]
+ int SetBkColor(int clrBk, out int pclr);
+
+ [PreserveSig]
+ int GetBkColor(out int pclr);
+
+ [PreserveSig]
+ int BeginDrag(int iTrack, int dxHotspot, int dyHotspot);
+
+ [PreserveSig]
+ int EndDrag();
+
+ [PreserveSig]
+ int DragEnter(IntPtr hwndLock, int x, int y);
+
+ [PreserveSig]
+ int DragLeave(IntPtr hwndLock);
+
+ [PreserveSig]
+ int DragMove(int x, int y);
+
+ [PreserveSig]
+ int SetDragCursorImage(IImageList punk, int iDrag, int dxHotspot, int dyHotspot);
+
+ [PreserveSig]
+ int DragShowNolock([MarshalAs(UnmanagedType.Bool)] bool fShow);
+
+ [PreserveSig]
+ int GetDragImage(out POINT ppt, out POINT pptHotspot, ref Guid riid, out IntPtr ppv);
+
+ [PreserveSig]
+ int GetItemFlags(int i, out int dwFlags);
+
+ [PreserveSig]
+ int GetOverlayImage(int iOverlay, out int piIndex);
+ }
+
+ /// IMAGEINFO(IImageList.GetImageInfo 输出)。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct IMAGEINFO
+ {
+ public IntPtr hbmImage;
+ public IntPtr hbmMask;
+ public int Unused1;
+ public int Unused2;
+ public RECT rcImage;
+ }
+
+ /// RECT。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct RECT
+ {
+ public int left;
+ public int top;
+ public int right;
+ public int bottom;
+ }
+
+ /// POINT。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct POINT
+ {
+ public int x;
+ public int y;
+ }
+
+ ///
+ /// IShellItemImageFactory:外壳缩略图/高清图标工厂,唯一能拿到「资源管理器同款」缩略图的接口。
+ ///
+ [ComImport]
+ [Guid("bcc18b79-ba16-442f-80c4-8a59c30c463b")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ internal interface IShellItemImageFactory
+ {
+ /// 取该条目的位图。返回 S_OK 时 phbm 为 HBITMAP(调用方 DeleteObject)。
+ [PreserveSig]
+ int GetImage(SIZE size, int flags, out IntPtr phbm);
+
+ /// 不参与本工程,仅用于占位以保持 vtable 顺序正确。
+ [PreserveSig]
+ int GetImageEx(SIZE size, int flags, IntPtr pColor, out IntPtr phbm);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // 高层封装:HICON / HBITMAP → 32bpp BGRA 像素
+ // ─────────────────────────────────────────────────────────────
+
+ /// 32bpp BGRA 像素缓冲(自上而下,行距 = width * 4)。
+ public sealed class BgraBuffer
+ {
+ internal BgraBuffer(int width, int height, byte[] pixels)
+ {
+ Width = width;
+ Height = height;
+ Pixels = pixels;
+ }
+
+ public int Width { get; }
+ public int Height { get; }
+ public byte[] Pixels { get; }
+
+ /// Alpha 通道是否全 0(说明位图本身不带 alpha,需要靠亮度重建)。
+ public bool HasAlphaChannel()
+ {
+ for (int i = 3; i < Pixels.Length; i += 4)
+ {
+ if (Pixels[i] != 0) return true;
+ }
+
+ return false;
+ }
+ }
+
+ /// 解析名(shell: 语法 / CLSID)→ PIDL;失败返回 IntPtr.Zero。
+ internal static IntPtr ParseDisplayNameToPidl(string parsingName)
+ {
+ IntPtr pidl = IntPtr.Zero;
+ try
+ {
+ // SHParseDisplayName 对 "shell:RecycleBinFolder" 与 "::{CLSID}" 都有效
+ int hr = SHParseDisplayName(parsingName, IntPtr.Zero, out pidl, 0, out _);
+ if (hr < 0)
+ {
+ if (pidl != IntPtr.Zero)
+ {
+ CoTaskMemFree(pidl);
+ }
+
+ return IntPtr.Zero;
+ }
+
+ return pidl;
+ }
+ catch (DllNotFoundException)
+ {
+ return IntPtr.Zero;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return IntPtr.Zero;
+ }
+ }
+
+ /// 按真实路径取系统图标索引;失败返回 -1。目录/驱动器/文件都走这条。
+ internal static int GetSystemIconIndexByPath(string path, uint attributes, uint extraFlags)
+ {
+ var info = new SHFILEINFOW { szDisplayName = string.Empty, szTypeName = string.Empty };
+ uint flags = SHGFI_SYSICONINDEX | extraFlags;
+ IntPtr result = SHGetFileInfoByPath(path, attributes, ref info, (uint)Marshal.SizeOf(), flags);
+ return result == IntPtr.Zero ? -1 : info.iIcon;
+ }
+
+ /// 按 PIDL 取系统图标索引(shell: 解析名);失败返回 -1。
+ internal static int GetSystemIconIndexByPidl(IntPtr pidl, uint extraFlags)
+ {
+ if (pidl == IntPtr.Zero) return -1;
+ var info = new SHFILEINFOW { szDisplayName = string.Empty, szTypeName = string.Empty };
+ uint flags = SHGFI_SYSICONINDEX | SHGFI_PIDL | extraFlags;
+ IntPtr result = SHGetFileInfoByPidl(pidl, 0, ref info, (uint)Marshal.SizeOf(), flags);
+ return result == IntPtr.Zero ? -1 : info.iIcon;
+ }
+
+ ///
+ /// 按真实路径直接取 HICON(SHGetFileInfoW + SHGFI_ICON)。
+ /// 小尺寸最贴近资源管理器列表视图;调用方必须在 finally 里 DestroyIcon。
+ ///
+ internal static IntPtr GetHIconByPath(string path, uint attributes, uint extraFlags)
+ {
+ var info = new SHFILEINFOW { szDisplayName = string.Empty, szTypeName = string.Empty };
+ uint flags = SHGFI_ICON | extraFlags;
+ IntPtr result = SHGetFileInfoByPath(path, attributes, ref info, (uint)Marshal.SizeOf(), flags);
+ return result == IntPtr.Zero ? IntPtr.Zero : info.hIcon;
+ }
+
+ /// 按 PIDL 直接取 HICON(shell: 解析名);调用方负责 DestroyIcon。
+ internal static IntPtr GetHIconByPidl(IntPtr pidl, uint extraFlags)
+ {
+ if (pidl == IntPtr.Zero) return IntPtr.Zero;
+ var info = new SHFILEINFOW { szDisplayName = string.Empty, szTypeName = string.Empty };
+ uint flags = SHGFI_ICON | SHGFI_PIDL | extraFlags;
+ IntPtr result = SHGetFileInfoByPidl(pidl, 0, ref info, (uint)Marshal.SizeOf(), flags);
+ return result == IntPtr.Zero ? IntPtr.Zero : info.hIcon;
+ }
+
+ ///
+ /// 从系统图像列表按索引取指定尺寸的 HICON(256/48/32/16);调用方负责 DestroyIcon。
+ /// 返回的 HICON 是外壳原版图标资源在该尺寸下的真实副本(不是放大后的位图)。
+ ///
+ internal static IntPtr GetHIconFromSystemImageList(int imageListKind, int iconIndex)
+ {
+ if (iconIndex < 0) return IntPtr.Zero;
+
+ IntPtr hImageList = IntPtr.Zero;
+ try
+ {
+ Guid iid = typeof(IImageList).GUID;
+ if (SHGetImageListRaw(imageListKind, ref iid, out hImageList) < 0 || hImageList == IntPtr.Zero)
+ {
+ return IntPtr.Zero;
+ }
+
+ // 索引越界时 comctl32 会返回 NULL,这里先挡一道,省得白跑一次
+ if (ImageList_GetImageCount(hImageList) <= iconIndex) return IntPtr.Zero;
+
+ // ILD_TRANSPARENT = 1:保留图标自身的透明与 alpha
+ return ImageList_GetIcon(hImageList, iconIndex, 1);
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return IntPtr.Zero;
+ }
+ finally
+ {
+ // SHGetImageListRaw 已经 AddRef,用完后必须 Release
+ if (hImageList != IntPtr.Zero) Marshal.Release(hImageList);
+ }
+ }
+
+ /// 读系统图像列表在指定尺寸下的图标边长(用于诊断与兜底,不用它决定渲染尺寸)。
+ internal static int GetSystemImageListIconSize(int imageListKind)
+ {
+ IntPtr hImageList = IntPtr.Zero;
+ try
+ {
+ Guid iid = typeof(IImageList).GUID;
+ if (SHGetImageListRaw(imageListKind, ref iid, out hImageList) < 0 || hImageList == IntPtr.Zero)
+ {
+ return 0;
+ }
+
+ int cx = 0, cy = 0;
+ return ImageList_GetIconSize(hImageList, ref cx, ref cy) ? cx : 0;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return 0;
+ }
+ finally
+ {
+ if (hImageList != IntPtr.Zero) Marshal.Release(hImageList);
+ }
+ }
+
+ ///
+ /// HICON → 32bpp BGRA 像素(自上而下)。
+ /// 主路径:GetIconInfo 拿尺寸 → 32bpp DIB → DrawIconEx → GetDIBits 读回。
+ /// 若 GDI 绘制把 alpha 通道抹成 0(常见于只带掩码的老式图标),
+ /// 再用「黑底 vs 白底两次绘制」重建 alpha:像素在两次绘制中完全一致即为透明区。
+ ///
+ internal static BgraBuffer? IconToBgra(IntPtr hIcon, int width, int height)
+ {
+ if (hIcon == IntPtr.Zero || width <= 0 || height <= 0) return null;
+
+ IntPtr hdc = IntPtr.Zero;
+ IntPtr hDib = IntPtr.Zero;
+ IntPtr hOldBitmap = IntPtr.Zero;
+ IntPtr dibBits = IntPtr.Zero;
+
+ bool iconInfoValid = false;
+ ICONINFO iconInfo = default;
+
+ try
+ {
+ iconInfoValid = GetIconInfo(hIcon, out iconInfo);
+
+ // 1) 建 32bpp 自上而下 DIB(biHeight 取负 = top-down,行序与 BGRA 缓冲一致)
+ hdc = CreateCompatibleDC(IntPtr.Zero);
+ if (hdc == IntPtr.Zero) return null;
+
+ var bmi = CreateBgraBitmapInfo(width, height);
+
+ hDib = CreateDIBSection(hdc, ref bmi, DIB_RGB_COLORS, out dibBits, IntPtr.Zero, 0);
+ if (hDib == IntPtr.Zero || dibBits == IntPtr.Zero) return null;
+
+ hOldBitmap = SelectObject(hdc, hDib);
+
+ // 2) 判断这个 HICON 是不是"真 alpha"图标(颜色位图自带 alpha 通道)。
+ // 这决定了能不能直接信任 GDI 写的 alpha:
+ // · 真 alpha 图标 → DrawIconEx 会写正确的 alpha,直接采信即可;
+ // · 只有 1bpp AND 掩码的老式图标 → 必须靠掩码/双背景对比重建透明度。
+ bool hasRealAlpha = BitmapHasAlphaChannel(iconInfoValid ? iconInfo.hbmColor : IntPtr.Zero);
+
+ GetIconSize(hIcon, ref iconInfo, iconInfoValid, width, height, out int drawW, out int drawH);
+
+ // 清屏用「全透明黑」:
+ // - 真 alpha 图标:0 是正确初值,GDI 会补上被覆盖像素的 alpha;
+ // - 掩码图标:GDI 不写 alpha,留下 0 正好代表"未覆盖 = 透明",颜色保持黑。
+ // ⚠ 切记不要填成不透明黑(alpha=0xFF):那样未覆盖像素会变成"不透明黑",
+ // 不但无法识别透明,还会误判为 alpha 有效而跳过重建(此坑已被探针实测捕获)。
+ FillBits(dibBits, width * height * 4, 0x00, 0x00, 0x00, 0x00);
+
+ if (!DrawIconEx(hdc, 0, 0, hIcon, drawW, drawH, 0, IntPtr.Zero, DI_NORMAL))
+ {
+ return null;
+ }
+
+ var pixels = ReadDibSectionPixels(hdc, hDib, width, height);
+ if (pixels is null) return null;
+
+ // 3) 真 alpha 图标:验证 GDI 确实写了 alpha,成立就直接用,精度最高
+ if (hasRealAlpha)
+ {
+ var direct = new BgraBuffer(width, height, pixels);
+ bool usable = direct.HasAlphaChannel();
+ if (usable)
+ {
+ // 画布比图标大时(高 DPI 缩放),DrawIconEx 会把它画在左上角,这里搬回正中
+ CenterGlyph(pixels, width, height, drawW, drawH);
+ return new BgraBuffer(width, height, pixels);
+ }
+ }
+
+ // 4) 否则做双背景对比重建:白底再画一次,用两次结果解出 alpha 与原色
+ byte[]? light = RenderIconOnBackground(hIcon, width, height, drawW, drawH, 0xFF);
+ if (light is null)
+ {
+ // 白底渲染失败:退回黑底结果(黑底结果本身是 premultiplied,至少颜色正确)
+ CenterGlyph(pixels, width, height, drawW, drawH);
+ return new BgraBuffer(width, height, pixels);
+ }
+
+ RebuildAlphaViaContrast(pixels, light);
+
+ // 重建完 alpha 后再居中:居中只是搬运像素,放在 alpha 处理之后不会影响对比判定
+ CenterGlyph(pixels, width, height, drawW, drawH);
+ return new BgraBuffer(width, height, pixels);
+ }
+ catch (SEHException)
+ {
+ return null;
+ }
+ finally
+ {
+ // 严格释放,顺序:先还原 DC 选中的对象,再删对象,最后删 DC
+ if (hdc != IntPtr.Zero && hOldBitmap != IntPtr.Zero) SelectObject(hdc, hOldBitmap);
+ if (hDib != IntPtr.Zero) DeleteObject(hDib);
+ if (hdc != IntPtr.Zero) DeleteDC(hdc);
+
+ // GetIconInfo 拆出的两个位图必须释放,否则每次取图标泄漏两个 GDI 对象
+ if (iconInfoValid)
+ {
+ if (iconInfo.hbmMask != IntPtr.Zero) DeleteObject(iconInfo.hbmMask);
+ if (iconInfo.hbmColor != IntPtr.Zero) DeleteObject(iconInfo.hbmColor);
+ }
+ }
+ }
+
+ ///
+ /// 把 HICON 画到指定纯色背景上,返回 32bpp BGRA 像素(背景不透明)。
+ /// 与主流程共用同一套 DIB + DrawIconEx + GetDIBits 逻辑,避免两处实现不一致。
+ ///
+ private static byte[]? RenderIconOnBackground(IntPtr hIcon, int width, int height, int drawW, int drawH, byte background)
+ {
+ IntPtr hdc = IntPtr.Zero;
+ IntPtr hDib = IntPtr.Zero;
+ IntPtr hOldBitmap = IntPtr.Zero;
+
+ try
+ {
+ hdc = CreateCompatibleDC(IntPtr.Zero);
+ if (hdc == IntPtr.Zero) return null;
+
+ var bmi = CreateBgraBitmapInfo(width, height);
+ hDib = CreateDIBSection(hdc, ref bmi, DIB_RGB_COLORS, out IntPtr dibBits, IntPtr.Zero, 0);
+ if (hDib == IntPtr.Zero || dibBits == IntPtr.Zero) return null;
+
+ hOldBitmap = SelectObject(hdc, hDib);
+
+ // 背景必须是不透明的(alpha=0xFF):这样 alpha 合成才有确定的结果
+ FillBits(dibBits, width * height * 4, background, background, background, 0xFF);
+
+ if (!DrawIconEx(hdc, 0, 0, hIcon, drawW, drawH, 0, IntPtr.Zero, DI_NORMAL)) return null;
+
+ return ReadDibSectionPixels(hdc, hDib, width, height);
+ }
+ catch (SEHException)
+ {
+ return null;
+ }
+ finally
+ {
+ if (hdc != IntPtr.Zero && hOldBitmap != IntPtr.Zero) SelectObject(hdc, hOldBitmap);
+ if (hDib != IntPtr.Zero) DeleteObject(hDib);
+ if (hdc != IntPtr.Zero) DeleteDC(hdc);
+ }
+ }
+
+ /// 用 GetDIBits 把已选入 DC 的 32bpp DIB 读成自上而下的 BGRA 缓冲。
+ private static byte[]? ReadDibSectionPixels(IntPtr hdc, IntPtr hDib, int width, int height)
+ {
+ var readBmi = CreateBgraBitmapInfo(width, height);
+ var pixels = new byte[width * height * 4];
+ unsafe
+ {
+ fixed (byte* p = pixels)
+ {
+ int lines = GetDIBits(hdc, hDib, 0, (uint)height, (IntPtr)p, ref readBmi, DIB_RGB_COLORS);
+ if (lines == 0) return null;
+ }
+ }
+
+ return pixels;
+ }
+
+ /// 构造 32bpp 自上而下 DIB 的 BITMAPINFO。
+ private static BITMAPINFO CreateBgraBitmapInfo(int width, int height) => new()
+ {
+ bmiHeader = new BITMAPINFOHEADER
+ {
+ biSize = (uint)Marshal.SizeOf(),
+ biWidth = width,
+ // 负高度 = top-down,行序与 BGRA 缓冲一致
+ biHeight = -height,
+ biPlanes = 1,
+ biBitCount = 32,
+ biCompression = BI_RGB,
+ },
+ };
+
+ ///
+ /// 判断一个 HBITMAP 的颜色位图是否自带真实 alpha 通道。
+ /// 方法:把它读成 32bpp 后看 alpha 字节;全 0 说明它只有 1bpp AND 掩码(老式图标)。
+ ///
+ private static bool BitmapHasAlphaChannel(IntPtr hbmColor)
+ {
+ if (hbmColor == IntPtr.Zero) return false;
+
+ IntPtr hdc = IntPtr.Zero;
+ IntPtr hDib = IntPtr.Zero;
+ IntPtr hOldBitmap = IntPtr.Zero;
+
+ try
+ {
+ var native = new BITMAP();
+ if (GetObjectBitmap(hbmColor, Marshal.SizeOf(), ref native) <= 0) return false;
+ if (native.bmWidth <= 0 || native.bmHeight <= 0 || native.bmBitsPixel < 32) return false;
+
+ int width = native.bmWidth;
+ int height = native.bmHeight;
+ int byteCount = width * height * 4;
+
+ hdc = CreateCompatibleDC(IntPtr.Zero);
+ if (hdc == IntPtr.Zero) return false;
+
+ var bmi = CreateBgraBitmapInfo(width, height);
+ hDib = CreateDIBSection(hdc, ref bmi, DIB_RGB_COLORS, out IntPtr dibBits, IntPtr.Zero, 0);
+ if (hDib == IntPtr.Zero || dibBits == IntPtr.Zero) return false;
+
+ hOldBitmap = SelectObject(hdc, hDib);
+ FillBits(dibBits, byteCount, 0, 0, 0, 0);
+
+ var pixels = new byte[byteCount];
+ unsafe
+ {
+ fixed (byte* p = pixels)
+ {
+ if (GetDIBits(hdc, hbmColor, 0, (uint)height, (IntPtr)p, ref bmi, DIB_RGB_COLORS) == 0) return false;
+ }
+ }
+
+ for (int i = 3; i < byteCount; i += 4)
+ {
+ if (pixels[i] != 0) return true;
+ }
+
+ return false;
+ }
+ catch (SEHException)
+ {
+ return false;
+ }
+ finally
+ {
+ if (hdc != IntPtr.Zero && hOldBitmap != IntPtr.Zero) SelectObject(hdc, hOldBitmap);
+ if (hDib != IntPtr.Zero) DeleteObject(hDib);
+ if (hdc != IntPtr.Zero) DeleteDC(hdc);
+ }
+ }
+
+ ///
+ /// HBITMAP(通常来自 IShellItemImageFactory)→ 32bpp BGRA 像素。
+ /// 该路径返回的是 DDB,必须借助内存 DC 用 GetDIBits 转换。
+ ///
+ internal static BgraBuffer? BitmapToBgra(IntPtr hBitmap, int requestW, int requestH)
+ {
+ if (hBitmap == IntPtr.Zero) return null;
+
+ IntPtr hdc = IntPtr.Zero;
+ IntPtr hDib = IntPtr.Zero;
+ IntPtr hOldBitmap = IntPtr.Zero;
+
+ try
+ {
+ // 1) 先问 GDI 这张位图的真实尺寸
+ int width = requestW;
+ int height = requestH;
+ var native = new BITMAP();
+ if (GetObjectBitmap(hBitmap, Marshal.SizeOf(), ref native) > 0 && native.bmWidth > 0 && native.bmHeight > 0)
+ {
+ width = native.bmWidth;
+ height = native.bmHeight;
+ }
+
+ if (width <= 0 || height <= 0) return null;
+
+ hdc = CreateCompatibleDC(IntPtr.Zero);
+ if (hdc == IntPtr.Zero) return null;
+
+ // 2) 建 32bpp top-down DIB,选入 DC 后对其调用 GetDIBits
+ var bmi = new BITMAPINFO
+ {
+ bmiHeader = new BITMAPINFOHEADER
+ {
+ biSize = (uint)Marshal.SizeOf(),
+ biWidth = width,
+ biHeight = -height,
+ biPlanes = 1,
+ biBitCount = 32,
+ biCompression = BI_RGB,
+ },
+ };
+
+ hDib = CreateDIBSection(hdc, ref bmi, DIB_RGB_COLORS, out IntPtr dibBits, IntPtr.Zero, 0);
+ if (hDib == IntPtr.Zero || dibBits == IntPtr.Zero) return null;
+
+ hOldBitmap = SelectObject(hdc, hDib);
+ ClearBits(dibBits, width * height * 4);
+
+ var pixels = new byte[width * height * 4];
+ unsafe
+ {
+ fixed (byte* p = pixels)
+ {
+ int lines = GetDIBits(hdc, hBitmap, 0, (uint)height, (IntPtr)p, ref bmi, DIB_RGB_COLORS);
+ if (lines == 0) return null;
+ }
+ }
+
+ return new BgraBuffer(width, height, pixels);
+ }
+ catch (SEHException)
+ {
+ return null;
+ }
+ finally
+ {
+ if (hdc != IntPtr.Zero && hOldBitmap != IntPtr.Zero) SelectObject(hdc, hOldBitmap);
+ if (hDib != IntPtr.Zero) DeleteObject(hDib);
+ if (hdc != IntPtr.Zero) DeleteDC(hdc);
+ }
+ }
+
+ /// 取 HICON 的真实像素尺寸(GetIconInfo 的颜色/掩码位图尺寸),失败则回退到请求尺寸。
+ private static void GetIconSize(IntPtr hIcon, ref ICONINFO iconInfo, bool iconInfoValid, int fallbackW, int fallbackH, out int width, out int height)
+ {
+ width = fallbackW;
+ height = fallbackH;
+ if (!iconInfoValid) return;
+
+ IntPtr probe = iconInfo.hbmColor != IntPtr.Zero ? iconInfo.hbmColor : iconInfo.hbmMask;
+ if (probe == IntPtr.Zero) return;
+
+ var bm = new BITMAP();
+ if (GetObjectBitmap(probe, Marshal.SizeOf(), ref bm) <= 0) return;
+
+ // 只有掩码位图时高度是「颜色 + 掩码」两倍,取一半
+ int h = bm.bmHeight;
+ if (iconInfo.hbmColor == IntPtr.Zero) h /= 2;
+
+ if (bm.bmWidth > 0) width = bm.bmWidth;
+ if (h > 0) height = h;
+ }
+
+ ///
+ /// 把左上角的图标图形搬到画布正中。
+ /// DrawIconEx 总是从 (0,0) 开始画,当请求画布大于图标自身尺寸时(高 DPI 下必然发生,
+ /// 例如逻辑 32px × 缩放 2.0 = 64px 画布 + 32px 图标),不居中就会出现"图标缩在左上角"。
+ ///
+ private static void CenterGlyph(byte[] pixels, int width, int height, int glyphWidth, int glyphHeight)
+ {
+ if (glyphWidth <= 0 || glyphHeight <= 0) return;
+ if (glyphWidth >= width && glyphHeight >= height) return;
+
+ int offsetX = (width - glyphWidth) / 2;
+ int offsetY = (height - glyphHeight) / 2;
+ if (offsetX == 0 && offsetY == 0) return;
+
+ var moved = new byte[pixels.Length];
+ for (int y = 0; y < glyphHeight; y++)
+ {
+ int destY = y + offsetY;
+ if (destY < 0 || destY >= height) continue;
+
+ Buffer.BlockCopy(
+ pixels,
+ (y * width) * 4,
+ moved,
+ (destY * width + offsetX) * 4,
+ glyphWidth * 4);
+ }
+
+ Buffer.BlockCopy(moved, 0, pixels, 0, pixels.Length);
+ }
+
+ ///
+ /// 由「黑底 / 白底」两次绘制结果解出 alpha 与颜色,就地写回 。
+ ///
+ ///
+ /// 原理:像素 = 原色 × a + 底色 × (1 - a)
+ /// · 黑底 C_b = 原色 × a (即 premultiplied 值,alpha 与原色成对出现)
+ /// · 白底 C_w = 原色 × a + 255 × (1 - a)
+ /// 于是 delta = C_w - C_b = 255 × (1 - a) ⇒ a = 255 - delta,原色 = C_b / a。
+ ///
+ /// 两个必须踩过的坑(均由 _icon_probe 实测捕获):
+ /// 1) 完全透明区:两次绘制结果**逐字节相同**(黑底与白底都原样保留背景色)。判据是"相等",
+ /// 不能只看黑底是不是黑色——否则白色/接近白色的像素会被误判成透明(白色图标会整体消失)。
+ /// 2) 二次判定:对 a=255 的像素,C_b 就是原色;但若原色本身接近白色,C_b 也接近白,
+ /// 会与"黑底背景"混淆。这里用邻居像素的判定结果纠正:图标内部被不透明像素包围的像素
+ /// 不可能是透明的。图标图案很细,单轮邻居传播已经足够。
+ ///
+ private static void RebuildAlphaViaContrast(byte[] dark, byte[] light)
+ {
+ int pixelCount = dark.Length / 4;
+ var transparent = new bool[pixelCount];
+
+ // 第一轮:严格相等判据
+ for (int i = 0, p = 0; i < dark.Length; i += 4, p++)
+ {
+ transparent[p] = dark[i] == light[i]
+ && dark[i + 1] == light[i + 1]
+ && dark[i + 2] == light[i + 2];
+ }
+
+ // 第二轮:邻居纠正(上下左右四邻,只做一轮,避免误差扩散)
+ int width = 0;
+ int height = 0;
+ // 由长度反推宽度:本工程里图标总是正方形,这里按正方形处理
+ int edge = (int)Math.Sqrt(pixelCount);
+ if (edge > 0 && edge * edge == pixelCount)
+ {
+ width = edge;
+ height = edge;
+ }
+
+ if (width > 0 && height > 0)
+ {
+ var corrected = new bool[pixelCount];
+ Array.Copy(transparent, corrected, pixelCount);
+
+ for (int y = 1; y < height - 1; y++)
+ {
+ for (int x = 1; x < width - 1; x++)
+ {
+ int p = y * width + x;
+ if (!transparent[p]) continue;
+
+ // 四邻里有 3 个及以上是不透明像素 ⇒ 自己是被图案包围的内部像素,改判为不透明
+ int opaqueNeighbors = 0;
+ if (!transparent[p - 1]) opaqueNeighbors++;
+ if (!transparent[p + 1]) opaqueNeighbors++;
+ if (!transparent[p - width]) opaqueNeighbors++;
+ if (!transparent[p + width]) opaqueNeighbors++;
+ if (opaqueNeighbors >= 3) corrected[p] = false;
+ }
+ }
+
+ transparent = corrected;
+ }
+
+ // 第三轮:按 alpha 反解原色并重新预乘(SoftwareBitmap 需要 Premultiplied)
+ for (int i = 0, p = 0; i < dark.Length; i += 4, p++)
+ {
+ if (transparent[p])
+ {
+ dark[i] = 0;
+ dark[i + 1] = 0;
+ dark[i + 2] = 0;
+ dark[i + 3] = 0;
+ continue;
+ }
+
+ int bD = dark[i];
+ int gD = dark[i + 1];
+ int rD = dark[i + 2];
+
+ int delta = ((light[i] - bD) + (light[i + 1] - gD) + (light[i + 2] - rD)) / 3;
+ if (delta <= 0)
+ {
+ // C_w == C_b 但第一轮判定不是"完全相等"(差在其它通道),按完全不透明处理
+ dark[i + 3] = 255;
+ continue;
+ }
+
+ int a = 255 - delta;
+ if (a <= 0)
+ {
+ dark[i] = 0;
+ dark[i + 1] = 0;
+ dark[i + 2] = 0;
+ dark[i + 3] = 0;
+ continue;
+ }
+
+ // 原色 = C_b × 255 / a,再按 Premultiplied 重新折算回 C_b(此处即保持 C_b 不变)
+ dark[i + 3] = (byte)a;
+ }
+ }
+
+ /// 按 BGRA 颜色填充像素缓冲(byteCount 必须是 4 的倍数)。
+ private static void FillBits(IntPtr bits, int byteCount, byte b, byte g, byte r, byte a)
+ {
+ unsafe
+ {
+ var p = (byte*)bits;
+ for (int i = 0; i < byteCount; i += 4)
+ {
+ p[i] = b;
+ p[i + 1] = g;
+ p[i + 2] = r;
+ p[i + 3] = a;
+ }
+ }
+ }
+
+ /// 清零像素缓冲(全透明黑)。
+ private static void ClearBits(IntPtr bits, int byteCount) => FillBits(bits, byteCount, 0, 0, 0, 0);
+}
diff --git a/Services/Icons/ShellParsingName.cs b/Services/Icons/ShellParsingName.cs
new file mode 100644
index 0000000..1fe6a3d
--- /dev/null
+++ b/Services/Icons/ShellParsingName.cs
@@ -0,0 +1,49 @@
+namespace FluidExplorer.Services.Icons;
+
+///
+/// 外壳解析名(parsing name)的小工具。
+/// 资源管理器/地址栏里出现的 "shell:RecycleBinFolder"、"{20D04FE0-3AEA-1069-A2D8-08002B30309D}"(此电脑)
+/// 都不是文件系统路径,必须先经 SHParseDisplayName 解析成 PIDL 才能取图标。
+///
+internal static class ShellParsingName
+{
+ /// "shell:" 前缀(大小写不敏感)。
+ internal const string ShellPrefix = "shell:";
+
+ /// 是不是 "shell:xxx" 形式的解析名。
+ internal static bool IsShellPrefix(string? parsingName)
+ => !string.IsNullOrEmpty(parsingName)
+ && parsingName.StartsWith(ShellPrefix, StringComparison.OrdinalIgnoreCase);
+
+ /// 是不是 "::{CLSID}" 形式的解析名(此电脑、回收站等已知外壳对象的经典写法)。
+ internal static bool IsGuidPidl(string? parsingName)
+ => !string.IsNullOrEmpty(parsingName)
+ && parsingName.StartsWith("::", StringComparison.Ordinal);
+
+ /// 本工程支持的外壳解析名(取图标时可以走 PIDL 路径)。
+ internal static bool IsParsingName(string? parsingName)
+ => IsShellPrefix(parsingName) || IsGuidPidl(parsingName);
+
+ /// 补全 "shell:" 前缀:传入 "RecycleBinFolder" 也能用。
+ 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";
+}
diff --git a/Services/ItemVisuals/ItemVisualService.cs b/Services/ItemVisuals/ItemVisualService.cs
new file mode 100644
index 0000000..09fd4d1
--- /dev/null
+++ b/Services/ItemVisuals/ItemVisualService.cs
@@ -0,0 +1,169 @@
+using FluidExplorer.Models;
+using FluidExplorer.Services.Icons;
+using FluidExplorer.ViewModels;
+using Microsoft.UI.Dispatching;
+
+namespace FluidExplorer.Services.ItemVisuals;
+
+///
+/// 列表里每一行的图标/缩略图按需加载:只为真正可见的行发请求,
+/// 内置并发上限与去重,滚动再快也不会把外壳图标接口打爆。
+///
+public sealed class ItemVisualService(IIconService icons, DispatcherQueue ui)
+{
+ /// 诊断开关:FLUID_DISABLE_ICONS=1 时完全跳过外壳取图(用于隔离图标路径引发的问题)。
+ private static readonly bool IconsDisabled = Environment.GetEnvironmentVariable("FLUID_DISABLE_ICONS") == "1";
+
+ private static readonly HashSet 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 _inFlight = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object _sync = new();
+
+ public bool IsThumbnailCandidate(string extension) => ThumbnailExtensions.Contains(extension);
+
+ /// 为一行请求小图标(列表/详情视图)。
+ 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();
+ }
+ });
+ }
+
+ /// 为一行请求缩略图(网格视图);失败则保留图标。
+ 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();
+ }
+ });
+ }
+
+ /// 导航离开时清掉去重表(已缓存的位图仍在图标服务里)。
+ 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;
+ }
+}
diff --git a/Services/Operations/CopyEngine.cs b/Services/Operations/CopyEngine.cs
new file mode 100644
index 0000000..e612f06
--- /dev/null
+++ b/Services/Operations/CopyEngine.cs
@@ -0,0 +1,690 @@
+using System.Buffers;
+
+namespace FluidExplorer.Services.Operations;
+
+/// 单个条目(文件/目录)处理后的结果。
+internal enum EntryOutcome
+{
+ Success,
+ Skipped,
+ Failed,
+ Cancelled
+}
+
+///
+/// 拷贝引擎与作业运行器之间的回调边界。
+/// 引擎本身不认识 FileOperationJob / UI,只通过这个接口上报进度、询问冲突、请求取消,
+/// 因此可以脱离队列单独测试与复用。
+///
+internal interface IJobSink
+{
+ CancellationToken Token { get; }
+
+ bool IsCancellationRequested { get; }
+
+ /// 若作业处于暂停态则挂起,直到继续或取消。每个拷贝块之间调用。
+ Task WaitIfPausedAsync();
+
+ void AddBytes(long delta);
+
+ void AddCompletedItems(int delta);
+
+ void SetCurrentItem(string path);
+
+ /// 记录一条非致命警告(重解析点跳过、时间戳设置失败等),进 job.Error。
+ void Warn(string message);
+
+ /// 记录一个失败条目;引擎会继续处理其余文件,绝不中止整批。
+ void AddFailed(string path, string reason);
+
+ void AddSkipped(int delta = 1);
+
+ ///
+ /// 记录一次真实的"搬运"以便撤销。
+ /// 语义: 是搬运后的当前位置, 是原位置;
+ /// 撤销时把 newPath 搬回 originalPath。(同卷移动是 1 条;目录合并移动会产生多条。)
+ ///
+ void RecordMoveForUndo(string newPath, string originalPath);
+
+ /// 向 UI 询问冲突处理方式。未设置回调 / 非 Ask 策略时由运行器按 Policy 直接决定,不阻塞。
+ Task ResolveConflictAsync(ConflictInfo info);
+
+ /// 用户选择了"取消",终止整个作业(已完成的部分保留,不回滚)。
+ void RequestCancel();
+}
+
+///
+/// 文件复制 / 移动 / 删除的核心实现。
+///
+/// 关键设计:
+/// - 所有 Win32 文件 IO 走 \\?\ 长路径前缀(见 );
+/// - 1MB 缓冲 + SequentialScan + 异步 IO,块与块之间检查暂停/取消;
+/// - 单文件失败只重试 3 次(100/300/900ms)后计入失败列表并继续,绝不因单个文件中断整批;
+/// - 同卷 Move 走 File.Move/Directory.Move(瞬时、不搬字节),跨卷才 Copy+Delete;
+/// 之所以不用 MoveFileEx/直接调 API:那样虽然也能跨卷搬,但拿不到字节级进度,
+/// 而"精确进度 + 可暂停/取消"是本引擎的核心诉求。
+///
+internal static class CopyEngine
+{
+ /// 拷贝块大小:1MB。
+ internal const int BufferSize = 1024 * 1024;
+
+ /// 重试间隔(毫秒):共重试 3 次。
+ private static readonly int[] RetryDelaysMs = [100, 300, 900];
+
+ // ---------------------------------------------------------------- 测量
+
+ ///
+ /// 递归统计总字节数与总条目数(目录本身也算 1 个条目,和执行阶段的计数口径一致)。
+ /// 不跟随重解析点;无法访问的项跳过。可取消。
+ ///
+ internal static (long Bytes, int Items) Measure(IReadOnlyList paths, CancellationToken cancellationToken, Action? 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();
+ 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);
+ }
+
+ // ---------------------------------------------------------------- 复制
+
+ /// 复制一个条目(文件或目录),内部处理冲突策略。
+ internal static async Task 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 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 reportBytes)
+ {
+ var sourceExtended = PathHelper.ToExtended(source);
+ var destinationExtended = PathHelper.ToExtended(destination);
+
+ // 目标已存在且只读:必须先去只读,否则 Create 会抛 UnauthorizedAccessException。
+ PathHelper.ClearReadOnly(destination);
+
+ var buffer = ArrayPool.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.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 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;
+ }
+
+ // ---------------------------------------------------------------- 移动
+
+ /// 移动一个条目,内部处理冲突策略。
+ internal static async Task 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);
+ }
+
+ ///
+ /// 不做冲突询问的移动(重命名、撤销还原用):
+ /// 调用方必须已经保证目标路径不冲突,或已经自行决定好冲突处理方式。
+ ///
+ internal static async Task 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 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;
+ }
+
+ ///
+ /// 目录合并移动:目标目录已存在时,逐个搬子项。
+ /// 同卷时每个子项都是瞬时的 File.Move;同名子项按冲突策略处理。
+ ///
+ private static async Task 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;
+ }
+
+ // ---------------------------------------------------------------- 删除
+
+ /// 永久删除(不进回收站)。目录采用"后序迭代删除",逐个条目上报进度。
+ internal static async Task 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;
+ }
+
+ // ---------------------------------------------------------------- 通用
+
+ /// 重试包装:IOException / UnauthorizedAccessException 重试 3 次(100/300/900ms),
+ /// 仍失败则计入失败列表并返回 false,由调用方继续处理其余文件。
+ private static async Task RetryAsync(Func 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;
+ }
+ }
+
+ /// candidate 是否等于 root 或位于 root 之内(用于拒绝"复制到自身内部")。
+ 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 了):保留,不算失败。
+ }
+ }
+}
diff --git a/Services/Operations/FileOperationService.cs b/Services/Operations/FileOperationService.cs
new file mode 100644
index 0000000..b5561d2
--- /dev/null
+++ b/Services/Operations/FileOperationService.cs
@@ -0,0 +1,922 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace FluidExplorer.Services.Operations;
+
+///
+/// 文件操作引擎(纯 .NET 实现,不引用任何 WinUI 类型,可独立测试与复用)。
+///
+/// 线程模型:
+/// - 所有作业都在后台 worker 上执行,UI 线程只负责入队/暂停/继续/取消,永不阻塞;
+/// - 默认串行执行(避免多作业同时读写同一块磁盘造成抖动);
+/// 同卷 Move / 重命名 / 新建文件夹这类"瞬时元数据操作"走并行车道,不占用串行队首;
+/// - 进度回调按 50ms 限频,避免 UI 事件风暴。
+///
+public sealed class FileOperationService : IFileOperationService, IDisposable
+{
+ /// 撤销栈上限:超出后丢弃最旧的条目。
+ private const int MaxUndoEntries = 30;
+
+ /// 进度上报限频(毫秒)。
+ private const int ProgressFlushIntervalMs = 50;
+
+ /// JobsChanged 限频(毫秒):进度类变化不按字节风暴式通知。
+ private const int JobsChangedThrottleMs = 250;
+
+ private const int MaxErrorLength = 2000;
+
+ private readonly JobQueue _queue;
+ private readonly Stack _undoStack = new();
+ private readonly object _undoGate = new();
+ private int _lastJobsChangedTick;
+ private bool _disposed;
+
+ public FileOperationService()
+ {
+ _queue = new JobQueue(ExecuteJobAsync);
+ _queue.Start();
+ }
+
+ // ------------------------------------------------------------ 队列与状态
+
+ public IReadOnlyList Jobs => _queue.Jobs;
+
+ public event EventHandler? JobsChanged
+ {
+ add => _queue.JobsChanged += value;
+ remove => _queue.JobsChanged -= value;
+ }
+
+ /// UI 设置冲突回调;未设置时 Ask 策略按 KeepBoth(保留两者)处理。
+ public Func>? 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();
+
+ /// 进度类变化按 250ms 限频触发 JobsChanged,避免逐字节通知造成 UI 事件风暴。
+ private void NotifyJobsChangedThrottled()
+ {
+ var now = Environment.TickCount;
+ if (unchecked(now - _lastJobsChangedTick) < JobsChangedThrottleMs) return;
+ _lastJobsChangedTick = now;
+ _queue.Raise();
+ }
+
+ // ------------------------------------------------------------ 入队
+
+ public FileOperationJob EnqueueCopy(IReadOnlyList sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
+ => EnqueueTransfer(FileOperationKind.Copy, sources, destination, policy);
+
+ public FileOperationJob EnqueueMove(IReadOnlyList sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
+ => EnqueueTransfer(FileOperationKind.Move, sources, destination, policy);
+
+ public FileOperationJob EnqueueDelete(IReadOnlyList 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 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 NormalizePaths(IReadOnlyList paths)
+ {
+ var result = new List();
+ 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;
+ }
+
+ ///
+ /// 目标路径解析(入队与执行两处必须一致):
+ /// - 目标是已存在的目录 / 末尾带分隔符 / 无扩展名 → 视为"放进该目录";
+ /// - 否则(单个源 + 目标不存在 + 带扩展名)→ 目标即完整目标路径,等价于"复制并改名"。
+ ///
+ private static List<(string Source, string Target)> ResolveTargets(IReadOnlyList 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 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\\ 做 $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;
+
+ ///
+ /// 一步撤销栈顶作业:Move/Rename 逐项搬回原位置,Recycle 把回收站里的 $R 搬回原始路径。
+ /// 所有 IO 都在线程池上执行(Task.Run + 异步 IO),UI await 即可,绝不会阻塞 UI 线程。
+ ///
+ public Task UndoAsync(CancellationToken cancellationToken = default)
+ => Task.Run(() => UndoCoreAsync(cancellationToken), CancellationToken.None);
+
+ private async Task 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);
+ }
+
+ /// 还原成功后顺手删掉对应的 $I 索引,避免回收站里留下指向不存在数据的死条目。
+ 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 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 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 ResolveConflictAsync(ConflictInfo info) => Task.FromResult(ConflictResolution.KeepBoth);
+
+ public void RequestCancel() { }
+ }
+
+ // ------------------------------------------------------------ 单个作业的运行器
+
+ ///
+ /// 单个作业的执行上下文:负责限频进度上报、统计成功/失败/跳过、收集撤销记录与诊断信息。
+ ///
+ private sealed class JobRunner : IJobSink
+ {
+ private readonly FileOperationService _owner;
+ private readonly JobContext _ctx;
+ private readonly Stopwatch _clock = Stopwatch.StartNew();
+ private readonly List _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;
+
+ /// 立即刷新一次进度并通知 UI(作业开始、阶段切换、结束等关键时刻)。
+ 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 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);
+ }
+
+ ///
+ /// 限频进度刷新:
+ /// - 数值属性(字节 / 条目 / 当前项 / 速度)每 50ms 写一次,各自只触发一个 PropertyChanged;
+ /// - 计算属性(Progress / Eta / CanPause…)每 250ms 通过一次 RaiseAll 刷新。
+ /// 于是 2000 个文件的复制只产生约 20 次/秒的 UI 通知,而不是"每个文件一次"的事件风暴。
+ ///
+ 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]}";
+ }
+ }
+}
diff --git a/Services/Operations/IFileOperationService.cs b/Services/Operations/IFileOperationService.cs
new file mode 100644
index 0000000..9de9af2
--- /dev/null
+++ b/Services/Operations/IFileOperationService.cs
@@ -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
+{
+ /// 交给 UI 决定(ConflictResolver)。
+ 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; }
+ /// true 表示用户勾选了"为后续所有冲突执行相同操作"。
+ public bool ApplyToAll { get; set; }
+}
+
+/// 队列里的一个作业:可暂停/继续/取消,进度实时上报(含速度与剩余时间)。
+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 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); }
+
+ /// 大小为 0 的作业(纯重命名等)显示旋转指示器。
+ 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(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
+ {
+ if (EqualityComparer.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; }
+ /// (原路径, 现路径) 对;撤销即反向搬运。
+ public required List<(string From, string To)> Moves { get; init; }
+ /// 删除操作记录:回收站里的 $R 文件路径 → 原始路径。
+ public List<(string RecyclePath, string OriginalPath)> Deleted { get; init; } = [];
+}
+
+public sealed record OperationResult(bool Success, int Succeeded, int Failed, int Skipped, string? Error = null);
+
+///
+/// 文件操作引擎:所有操作进入统一队列串行/并行执行,UI 永不阻塞。
+/// 支持暂停、继续、取消、冲突策略、错误重试,以及一步撤销(Ctrl+Z)。
+///
+public interface IFileOperationService
+{
+ IReadOnlyList Jobs { get; }
+ event EventHandler? JobsChanged;
+
+ /// UI 设置此回调以弹出冲突对话框;未设置时按 KeepBoth 处理。
+ Func>? ConflictResolver { get; set; }
+
+ FileOperationJob EnqueueCopy(IReadOnlyList sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask);
+ FileOperationJob EnqueueMove(IReadOnlyList sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask);
+ FileOperationJob EnqueueDelete(IReadOnlyList 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 UndoAsync(CancellationToken cancellationToken = default);
+
+ /// 计算源集合的总大小与条目数(后台执行,用于进度条与冲突提示)。
+ Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList paths, CancellationToken cancellationToken);
+}
diff --git a/Services/Operations/JobQueue.cs b/Services/Operations/JobQueue.cs
new file mode 100644
index 0000000..63be6c0
--- /dev/null
+++ b/Services/Operations/JobQueue.cs
@@ -0,0 +1,282 @@
+using System.Collections.ObjectModel;
+
+namespace FluidExplorer.Services.Operations;
+
+///
+/// 可异步等待的自动/手动复位事件:用于"暂停"语义。
+/// 关键点: 必须带 RunContinuationsAsynchronously,
+/// 否则 会在调用线程(通常是 UI 线程)上同步执行拷贝循环的续体,
+/// 从而把磁盘 IO 拖回 UI 线程。
+///
+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;
+ }
+}
+
+/// 引擎内部对一个作业的控制块:取消令牌 + 暂停闸门 + 调度车道标记。
+internal sealed class JobContext
+{
+ public JobContext(FileOperationJob job) => Job = job;
+
+ public FileOperationJob Job { get; }
+
+ public CancellationTokenSource Cts { get; } = new();
+
+ /// 初始为 Set(未暂停)。Reset 即暂停,Set 即继续。
+ public AsyncManualResetEvent PauseGate { get; } = new(initialState: true);
+
+ ///
+ /// true = 走"瞬时车道":同卷 Move / 重命名 / 新建文件夹这类不搬字节的操作,
+ /// 可以和其他作业并行,不必排队等大拷贝。
+ ///
+ public bool InstantLane { get; set; }
+
+ public bool Started { get; set; }
+}
+
+///
+/// 作业队列:后台 worker 严格按入队顺序调度。
+/// 默认串行(避免多任务同时读盘造成磁盘抖动、拖慢整体吞吐),
+/// 但"瞬时操作"(同卷 Move / 重命名 / 新建文件夹)允许并行,因为它们只做元数据操作。
+///
+internal sealed class JobQueue : IDisposable
+{
+ /// 瞬时车道最大并行度,防止一次排入上千个瞬时作业时线程爆炸。
+ private const int MaxInstantParallelism = 4;
+
+ private readonly Func _executor;
+ private readonly ObservableCollection _jobs = [];
+ private readonly List _pending = [];
+ private readonly Dictionary _contexts = [];
+ private readonly HashSet _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 executor) => _executor = executor;
+
+ public ObservableCollection 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();
+ }
+}
diff --git a/Services/Operations/PathHelper.cs b/Services/Operations/PathHelper.cs
new file mode 100644
index 0000000..066db31
--- /dev/null
+++ b/Services/Operations/PathHelper.cs
@@ -0,0 +1,330 @@
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+
+namespace FluidExplorer.Services.Operations;
+
+///
+/// 路径与文件系统元数据工具。
+///
+/// 设计约定(整个 Operations 引擎统一遵守):
+/// 1. 引擎内部、作业描述、UndoEntry 里保存的都是"普通路径"(不带 \\?\ 前缀),
+/// 只有真正触达 BCL / Win32 IO 的那一刻才通过 转换,
+/// 避免 \\?\ 前缀泄漏到 UI 显示、$I 解析、Shell API 调用里(Shell API 不接受前缀)。
+/// 2. 所有拼接都用 (手工拼分隔符),不用 Path.Combine 后直接丢给 API,
+/// 以保证超长路径(>260)在所有环节都能正确走到 \\?\ 分支。
+/// 3. 所有 IO 调用一律走 ,NET8 虽然自身也会兜底长路径,
+/// 但统一处理后行为可预期(尤其是 UNC:\\server\share → \\?\UNC\server\share)。
+///
+internal static partial class PathHelper
+{
+ internal const string ExtendedPrefix = @"\\?\";
+ internal const string ExtendedUncPrefix = @"\\?\UNC\";
+
+ [GeneratedRegex(@"^(.*) \((\d+)\)$", RegexOptions.CultureInvariant)]
+ private static partial Regex CopySuffixRegex();
+
+ /// 安全取全路径;非法路径(空、含非法字符、超长到无法规范化)返回 null 而不抛。
+ internal static string? TryGetFullPath(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return null;
+ try
+ {
+ return Path.GetFullPath(path);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ /// 转成 Win32 长路径形式(\\?\ 或 \\?\UNC\)。已是前缀形式则原样返回。
+ 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;
+ }
+
+ /// 去掉长路径前缀,得到可显示/可交给 Shell API 的普通路径。
+ 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;
+ }
+
+ /// 手工拼接子路径(不依赖 Path.Combine 的根路径语义)。
+ 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;
+ }
+
+ /// 取最后一段名字(同时兼容带/不带 \\?\ 前缀)。
+ 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)..];
+ }
+
+ /// 取父目录;已是卷根时返回卷根本身(不返回 null,方便调用方继续拼接)。
+ 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;
+ }
+
+ /// 取卷根(用于同卷判定);UNC 时返回 \\server\share\。
+ internal static string GetVolumeRoot(string path)
+ {
+ var full = TryGetFullPath(path) ?? StripExtended(path);
+ var root = Path.GetPathRoot(full);
+ return string.IsNullOrEmpty(root) ? full : root;
+ }
+
+ /// 是否同一卷(同卷 Move 才能走瞬时的 File.Move/Directory.Move)。
+ 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; }
+ }
+
+ /// 清掉只读属性,否则覆盖/删除会抛 UnauthorizedAccessException。
+ 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)
+ {
+ // 不存在或无权访问:交给真正的操作去抛错,这里不吞掉信息。
+ }
+ }
+
+ /// 确保父目录存在(撤销时目标父目录可能已被删掉)。
+ internal static void EnsureParentDirectory(string path)
+ {
+ var parent = GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(ToExtended(parent));
+ }
+
+ ///
+ /// 生成 "名字 (2).ext" 风格的不冲突路径;若本身已带 " (n)" 后缀,先剥离再递增,
+ /// 避免出现 "a (2) (2).txt" 这种叠加命名。
+ ///
+ 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}”生成不冲突的新名称。");
+ }
+
+ /// 校验文件名(重命名/新建文件夹用),错误信息为中文。
+ 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;
+ }
+
+ ///
+ /// 安全枚举某个目录的直接子项(返回普通路径)。
+ /// 单个子目录无权限/枚举中途出错时返回已拿到的部分并回调警告,绝不抛出。
+ ///
+ internal static List EnumerateChildrenSafe(string directory, Action? onWarning = null)
+ {
+ var result = new List();
+ IEnumerator? 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;
+ }
+
+ /// 把 \\?\ 前缀路径还原成普通路径(供 P/Invoke Shell API 使用)。
+ internal static string ToShellPath(string path) => StripExtended(path);
+
+ /// 判断 extended 前缀是否已存在(调试用)。
+ internal static bool HasExtendedPrefix(string path)
+ => path.StartsWith(ExtendedPrefix, StringComparison.Ordinal);
+
+ /// 分配 UTF-16 双 null 结尾路径列表(SHFileOperationW 要求)。
+ internal static IntPtr AllocDoubleNullList(IEnumerable paths)
+ {
+ var joined = string.Join('\0', paths) + "\0\0";
+ return Marshal.StringToHGlobalUni(joined);
+ }
+}
diff --git a/Services/Operations/RecycleBinLocator.cs b/Services/Operations/RecycleBinLocator.cs
new file mode 100644
index 0000000..7ba39cc
--- /dev/null
+++ b/Services/Operations/RecycleBinLocator.cs
@@ -0,0 +1,353 @@
+using System.Runtime.InteropServices;
+using System.Security.Principal;
+
+namespace FluidExplorer.Services.Operations;
+
+/// 回收站里一条 $I 索引记录解析出来的信息。
+internal sealed record RecycleBinItem(string IndexPath, string DataPath, string OriginalPath, long Size, DateTime DeletedUtc);
+
+///
+/// 回收站定位器:
+/// 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 截断,比依赖该字段更稳。
+///
+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);
+
+ // ------------------------------------------------------------ 删除到回收站
+
+ /// 把一批路径送进回收站。必须一次调用完成一批(Shell 语义)。
+ internal static (bool Success, bool Aborted, int Code) DeleteToRecycleBin(IReadOnlyList 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);
+ }
+ }
+
+ ///
+ /// 在专用 STA 线程上执行 SHFileOperationW。
+ /// Shell 函数在内部会做 COM/OLE 相关工作,用 STA 线程调用最稳妥;
+ /// 该线程是后台线程,不会阻塞 UI,也不会阻止进程退出。
+ ///
+ internal static Task<(bool Success, bool Aborted, int Code)> DeleteToRecycleBinAsync(IReadOnlyList 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 定位
+
+ /// 取当前进程用户的 SID 字符串(回收站目录名)。
+ internal static string? TryGetCurrentUserSid()
+ {
+ try
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ return identity.User?.Value;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ /// 某个卷的回收站目录:<卷>:\$Recycle.Bin\<SID>\(不存在返回 null)。
+ 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;
+ }
+
+ ///
+ /// 快照:卷 → 该卷回收站里所有 $I 文件的完整路径集合。
+ /// 主体扫描 <卷>:\$Recycle.Bin\<当前用户 SID>\,同时兜底扫描其它 SID 目录
+ /// (进程可能以别的账户删除过文件)。
+ ///
+ internal static Dictionary> CaptureState(IReadOnlyList paths)
+ {
+ var volumes = new List();
+ 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>(StringComparer.OrdinalIgnoreCase);
+ foreach (var volume in volumes) result[volume] = EnumerateIndexFiles(volume);
+ return result;
+ }
+
+ private static HashSet EnumerateIndexFiles(string volumeRoot)
+ {
+ var set = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var binRoot = PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin");
+ if (!PathHelper.DirectoryExists(binRoot)) return set;
+
+ var directories = new List();
+
+ 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;
+ }
+
+ ///
+ /// 用"操作前后目录快照差集"找出本次新增的回收站条目,并用 $I 里的原始路径做二次确认,
+ /// 产出 (回收站内 $R 数据路径, 原始路径) 列表,可直接填入 。
+ /// 定位失败的项也会产出记录(回收站路径为空串),撤销时按"无法还原"计入失败,不会崩溃。
+ ///
+ internal static (List<(string RecyclePath, string OriginalPath)> Items, List Diagnostics) ResolveDeletedItems(
+ IReadOnlyList deletedPaths,
+ Dictionary> before)
+ {
+ var items = new List<(string RecyclePath, string OriginalPath)>();
+ var diagnostics = new List();
+ var remaining = new List(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);
+ }
+
+ /// 解析单个 $I 索引文件。
+ 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(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);
+ }
+
+ /// 由 $R 数据路径反推对应的 $I 索引路径。
+ 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);
+ }
+}
diff --git a/Services/Placeholders.cs b/Services/Placeholders.cs
new file mode 100644
index 0000000..99a918d
--- /dev/null
+++ b/Services/Placeholders.cs
@@ -0,0 +1,83 @@
+using FluidExplorer.Services.Icons;
+using FluidExplorer.Services.Operations;
+using Microsoft.UI.Xaml.Media;
+
+namespace FluidExplorer.Services;
+
+///
+/// 降级占位实现:当真实现(外壳图标服务 / 文件操作引擎)在构造阶段抛异常时兜底,
+/// 保证主界面还能打开浏览(图标为空、操作给出明确失败提示),而不是整个程序起不来。
+///
+internal sealed class PlaceholderIconService : IIconService
+{
+ public Task GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken)
+ => Task.FromResult(null);
+
+ public Task GetThumbnailAsync(string path, int size, CancellationToken cancellationToken)
+ => Task.FromResult(null);
+
+ public Task GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken)
+ => Task.FromResult(null);
+
+ public ImageSource? GetSpecialFolderIcon(string parsingName, int size) => null;
+
+ public void ClearCache() { }
+}
+
+internal sealed class PlaceholderOperationService : IFileOperationService
+{
+ private readonly List _jobs = [];
+
+ public IReadOnlyList Jobs => _jobs;
+ public event EventHandler? JobsChanged;
+
+ // 占位实现永远不会产生撤销记录,事件显式忽略订阅以避免空事件告警
+ public event EventHandler? UndoStackChanged
+ {
+ add { }
+ remove { }
+ }
+
+ public Func>? ConflictResolver { get; set; }
+ public bool CanUndo => false;
+ public string? UndoDescription => null;
+
+ private FileOperationJob Fail(FileOperationKind kind, IReadOnlyList 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 sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
+ => Fail(FileOperationKind.Copy, sources, destination);
+
+ public FileOperationJob EnqueueMove(IReadOnlyList sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
+ => Fail(FileOperationKind.Move, sources, destination);
+
+ public FileOperationJob EnqueueDelete(IReadOnlyList 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 UndoAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(new OperationResult(false, 0, 0, 0, "没有可撤销的操作。"));
+
+ public Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList paths, CancellationToken cancellationToken)
+ => Task.FromResult((0L, 0));
+}
diff --git a/Services/Search/IFileIndex.cs b/Services/Search/IFileIndex.cs
new file mode 100644
index 0000000..0434d3e
--- /dev/null
+++ b/Services/Search/IFileIndex.cs
@@ -0,0 +1,77 @@
+namespace FluidExplorer.Services.Search;
+
+public enum IndexState
+{
+ NotStarted,
+ RequiresElevation,
+ Building,
+ Ready,
+ Watching,
+ Failed,
+ Stopped
+}
+
+/// 索引里的一条记录(NTFS USN 数据的最小集合)。
+public readonly record struct IndexedEntry(
+ ulong Frn,
+ ulong ParentFrn,
+ string Name,
+ bool IsDirectory,
+ long Size,
+ DateTime ModifiedUtc,
+ uint Attributes)
+{
+ /// 非 NTFS / 非索引来源(例如回退扫描器)可直接携带完整路径。
+ 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;
+}
+
+///
+/// 单卷文件索引:Everything 式体验的核心。
+/// 实现必须做到:构建阶段不阻塞调用线程(内部自行使用线程池)、
+/// 支持增量更新(USN 日志)、查询使用并行扫描并在毫秒级返回。
+///
+public interface IFileIndex
+{
+ /// 卷根,例如 "C:\"。
+ string VolumeRoot { get; }
+
+ IndexState State { get; }
+ long EntryCount { get; }
+
+ event EventHandler? StateChanged;
+
+ /// 建立初始索引(枚举 MFT / 扫描)。可重复调用,完成后自动转入 Watching。
+ Task BuildAsync(IProgress? progress, CancellationToken cancellationToken);
+
+ /// 启动增量监听(USN journal),保持索引实时。
+ void StartWatching();
+
+ void Stop();
+
+ ///
+ /// 查询。实现约定:
+ /// 1) 先按 IncludeTerms/Extensions/大小/时间/属性过滤(纯内存操作,必须并行化);
+ /// 2) 仅当 不为空时,才对已命中的候选调用路径解析;
+ /// 3) 命中数达到 maxResults 即可提前返回,但不要漏掉更"好"的匹配(短名优先)。
+ ///
+ IEnumerable Query(SearchQuery query, int maxResults, CancellationToken cancellationToken);
+
+ /// 把 FRN 解析为完整路径(内部要缓存父链解析结果)。失败返回 false。
+ bool TryResolvePath(ulong frn, out string fullPath);
+}
diff --git a/Services/Search/SearchQuery.cs b/Services/Search/SearchQuery.cs
new file mode 100644
index 0000000..13c0bab
--- /dev/null
+++ b/Services/Search/SearchQuery.cs
@@ -0,0 +1,200 @@
+using System.Globalization;
+using System.Text;
+
+namespace FluidExplorer.Services.Search;
+
+///
+/// Everything 风格的查询:空格 = AND,| = OR,"引号" = 精确短语,!term = 排除,
+/// 支持 ext: size: dm: dc: folder: file: path: 以及 * ? 通配符。
+///
+public sealed class SearchQuery
+{
+ public string RawText { get; init; } = string.Empty;
+ public List IncludeTerms { get; } = [];
+ public List ExcludeTerms { get; } = [];
+ public List IncludeRegexLike { get; } = []; // 含通配符的项
+ public List Extensions { get; } = [];
+ public List 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 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);
+ }
+
+ /// 按空格切词,但保留引号内的空格。
+ private static IEnumerable 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();
+ }
+}
diff --git a/Services/Search/SearchService.cs b/Services/Search/SearchService.cs
new file mode 100644
index 0000000..799308e
--- /dev/null
+++ b/Services/Search/SearchService.cs
@@ -0,0 +1,237 @@
+using FluidExplorer.Services.Shell;
+using FluidExplorer.Services.Search;
+using FluidExplorer.Services.FileSystem;
+
+namespace FluidExplorer.Services.Search;
+
+/// 一条搜索结果(对外给 UI 用的扁平结构)。
+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 Hits,
+ bool Truncated,
+ bool UsedIndex,
+ int IndexedVolumes,
+ TimeSpan Elapsed,
+ string? Note = null);
+
+///
+/// 搜索门面:优先使用 NTFS 索引(毫秒级、全盘),
+/// 没有索引时回退为带时间预算的实时枚举(并明确告诉用户"未使用索引")。
+///
+public sealed class SearchService(IFileSystemService fileSystem)
+{
+ private readonly Dictionary _indexes = new(StringComparer.OrdinalIgnoreCase);
+ private readonly IFileSystemService _fileSystem = fileSystem;
+
+ /// 由 AppServices 注入的具体索引实现工厂(这样本层不依赖具体互操作实现)。
+ public Func? 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;
+ }
+ }
+
+ /// 启动指定卷的索引(后台构建,不阻塞 UI)。
+ 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 AllIndexes => _indexes.Values;
+
+ /// 为当前所有固定卷建立索引(默认行为:只索引本地固定磁盘,避免扫网络盘)。
+ public async Task BuildIndexesAsync(IEnumerable 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(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);
+ }
+ }
+
+ /// 执行搜索。Global 走索引;索引不可用时退回实时扫描(并如实告知用户)。
+ public async Task 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(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;
+ }
+}
diff --git a/Services/Search/Usn/DirectoryChangeWatcher.cs b/Services/Search/Usn/DirectoryChangeWatcher.cs
new file mode 100644
index 0000000..a5caf72
--- /dev/null
+++ b/Services/Search/Usn/DirectoryChangeWatcher.cs
@@ -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;
+
+///
+/// 增量监听的回退通道:ReadDirectoryChangesW。
+///
+/// 什么情况下用:USN 变更日志不可用(卷上没日志且没权限创建、被策略禁用、日志刚被删除等)。
+/// 这条路径不需要任何特殊权限,普通用户也能跑,从而保证「索引实时」这个卖点不落空。
+///
+/// 与 USN 的差别与应对:
+/// * RDCW 只给相对路径,不给 FRN。这里用 CreateFileW(FILE_READ_ATTRIBUTES) +
+/// GetFileInformationByHandle 取句柄上的 FileIndex —— 它与 USN 的 FRN 完全同口径
+/// (低 48 位记录号 + 高 16 位序列号),因此能直接命中同一个索引条目。
+/// * 新增/改名/内容变化都伴随着文件仍然存在,可以 stat 出来 → 直接 upsert。
+/// * 删除只剩一个路径(stat 必然失败),无法反查 FRN。应对:把受影响的目录记下来,
+/// 批处理结束后对该目录做一次「磁盘现状 vs 索引」的对账(),
+/// 只把索引里存在、磁盘上已消失的孩子打墓碑。对账按目录去重、每轮有上限,代价可控。
+///
+internal sealed class DirectoryChangeWatcher
+{
+ private const int ReadBufferSize = 64 * 1024;
+
+ /// 每轮最多对账多少个目录,防止一次批量删除把 CPU 打满。
+ 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 _directoryFrnCache = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _pendingResync = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _resyncScratch = [];
+
+ private SafeFileHandle? _handle;
+ private volatile bool _stopRequested;
+
+ internal DirectoryChangeWatcher(UsnVolumeIndex index)
+ {
+ _index = index;
+ _root = index.VolumeRoot;
+ }
+
+ /// 打断阻塞中的 ReadDirectoryChangesW:置停止标志 + 关目录句柄(双保险)。
+ internal void RequestStop()
+ {
+ _stopRequested = true;
+ Interlocked.Exchange(ref _handle, null)?.Dispose();
+ }
+
+ /// 打开卷根目录句柄。返回 false 时 给出中文原因。
+ 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;
+ }
+
+ /// 阻塞式监听循环,直到 或句柄被关闭。
+ 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 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(buffer.Slice(nameOffset, nameBytes));
+ Handle(relative, action);
+
+ if (nextEntry == 0) break;
+ offset += (int)nextEntry;
+ }
+ }
+
+ private void Handle(ReadOnlySpan 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;
+ }
+ }
+
+ /// 按路径 stat 出 FRN + 元数据,然后按 FRN 落入索引(存在则更新,不存在则新增)。
+ 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);
+ }
+}
diff --git a/Services/Search/Usn/IndexStore.cs b/Services/Search/Usn/IndexStore.cs
new file mode 100644
index 0000000..38dd427
--- /dev/null
+++ b/Services/Search/Usn/IndexStore.cs
@@ -0,0 +1,212 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+
+namespace FluidExplorer.Services.Search.Usn;
+
+///
+/// 索引的紧凑存储:结构体数组(Struct-of-Arrays)而不是对象数组。
+///
+/// 每条记录只占 45 字节(8+8+8+8+8+4+1),100 万条约 45MB,
+/// 再加上名字池(约 2 字节/字符)就构成整个索引;绝无 per-entry 的对象头与 string。
+///
+/// 并发模型(读多写极少):
+/// * 用 volatile 发布:写入方先写满数据,最后自增 Count;读方只需读一次 Count 再顺序访问。
+/// * 结构只增不减(删除只打墓碑标志位),因此已经发布的 [0, Count) 区间永远有效。
+/// * 容量不足时 采用 copy-on-write,整体替换数组;老快照对正在查询的线程依然合法。
+///
+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;
+
+ ///
+ /// 名字池与数组快照绑定在一起:查询只需抓取一次 store 引用就能得到一致的 (名字池, 数组, 条数)。
+ /// 关键:扩容()必须复用同一个名字池 —— NameRef 里存的是池内偏移,
+ /// 换池等于让所有老记录的名字全部失效。
+ ///
+ internal readonly NamePool Names;
+
+ internal readonly int Capacity;
+
+ /// 已发布条数。写入方必须在写完所有字段之后再自增它。
+ 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;
+ }
+
+ /// 扩容(copy-on-write)。返回新快照,旧快照仍然可被并发查询安全使用。
+ 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;
+
+ /// 读名字(零分配)。
+ internal ReadOnlySpan GetName(int i)
+ {
+ long nameRef = Volatile.Read(ref NameRef[i]);
+ return Names.Get(NamePool.UnpackOffset(nameRef), NamePool.UnpackLength(nameRef));
+ }
+}
+
+///
+/// FRN(低 48 位记录号)→ 索引下标的映射。
+///
+/// 策略:构建期用普通 Dictionary 最快;构建结束后调用 ,
+/// 如果记录号足够密集(maxRecord <= 8 * count),就换成“记录号直接寻址”的 int[],
+/// 100 万文件只占几 MB(而 Dictionary 要 30~40MB)。
+/// 稀疏卷或监听期新增的越界记录号退回到 侧表。
+///
+internal sealed class FrnMap
+{
+ private const int NoIndex = 0;
+ private const int DenseSlack = 8;
+
+ private Dictionary? _buildMap;
+ private int[]? _dense; // 记录号 → 下标+1;0 表示不存在
+ private ConcurrentDictionary? _sparse;
+
+ internal FrnMap(int estimatedCount)
+ {
+ _buildMap = new Dictionary(Math.Max(16, estimatedCount));
+ }
+
+ internal int Count => _buildMap?.Count ?? _sparse?.Count ?? _dense?.Length ?? 0;
+
+ /// 构建期写入(单线程,无锁,最快)。
+ internal void AddBuild(ulong frn, int index)
+ {
+ _buildMap![UsnNative.NormalizeFrn(frn)] = index;
+ }
+
+ /// 构建完成后调用:把构建期的 Dictionary 压缩成密集数组或稀疏侧表。
+ 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();
+ foreach (var (key, value) in map)
+ {
+ sparse[key] = value;
+ }
+ Volatile.Write(ref _sparse, sparse);
+ }
+
+ /// 监听期写入(可能并发于查询,必须线程安全)。
+ 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();
+ sparse = Interlocked.CompareExchange(ref _sparse, created, null) ?? created;
+ }
+ sparse[record] = index;
+ }
+
+ /// 批量覆盖(重建索引时用,单线程)。
+ 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;
+ }
+
+ /// NormizeFrn 的别名,便于调用点表达意图。
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static ulong Normalize(ulong frn) => UsnNative.NormalizeFrn(frn);
+}
diff --git a/Services/Search/Usn/MftReader.cs b/Services/Search/Usn/MftReader.cs
new file mode 100644
index 0000000..cd3402d
--- /dev/null
+++ b/Services/Search/Usn/MftReader.cs
@@ -0,0 +1,354 @@
+using System.Buffers.Binary;
+using Microsoft.Win32.SafeHandles;
+
+namespace FluidExplorer.Services.Search.Usn;
+
+/// 从一条 $MFT 文件记录里解出来的关键字段。
+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
+}
+
+///
+/// 原始 $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 索引。
+///
+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;
+
+ /// MFT 在卷上的簇区间(已按字节换算),可用于日志与自检。
+ 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;
+ }
+
+ ///
+ /// 尝试建立 MftReader。失败(非 NTFS、无权限、run list 解析不出来)返回 null 并通过 说明原因。
+ ///
+ 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
+
+ ///
+ /// 应用 NTFS 的 fixup(Update Sequence Array)修正:
+ /// 每个扇区最后 2 个字节在磁盘上被换成了 USA 里的“更新序列号”,
+ /// 必须在解析前用 USA 中保存的真实值逐个还原,否则跨扇区的字段会是垃圾数据。
+ /// 返回 false 表示 USN 不匹配(记录在读取过程中被改写或已损坏),调用方应跳过该记录。
+ /// 设计成 static 是为了能被“合成记录”单元测试直接调用(无需真实卷句柄)。
+ ///
+ internal static bool ApplyFixup(Span 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;
+ }
+
+ // ================================================================ 属性解析
+
+ ///
+ /// 从记录里读取 $DATA(0x80) 的 mapping pairs(run list),换算成卷内字节区间。
+ /// 这里刻意手写属性链循环而不用委托回调:该路径在构建期会被调用百万次,闭包分配不可接受。
+ ///
+ private bool TryReadDataRuns(ReadOnlySpan 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;
+ }
+
+ /// 解析一条 MFT 记录的 $STANDARD_INFORMATION(0x10) / $FILE_NAME(0x30) / $DATA(0x80)。
+ internal static bool TryParseRecord(Span 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 record);
+
+ ///
+ /// 按区间流式读取整个 MFT,逐条回调(缓冲区复用,不产生 per-record 分配)。
+ /// 一般传 MftValidDataLength。
+ ///
+ internal void Enumerate(RecordVisitor visitor, long maxByteOffset, Action? 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;
+ }
+ }
+
+ ///
+ /// 按记录号读一条 MFT 记录(走 run list 换算物理偏移)。用于增量监听时刷新单个文件的大小/时间。
+ ///
+ internal bool TryReadRecord(ulong recordNumber, Span 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;
+ }
+}
diff --git a/Services/Search/Usn/NamePool.cs b/Services/Search/Usn/NamePool.cs
new file mode 100644
index 0000000..fef1a6d
--- /dev/null
+++ b/Services/Search/Usn/NamePool.cs
@@ -0,0 +1,83 @@
+namespace FluidExplorer.Services.Search.Usn;
+
+///
+/// 紧凑文件名池 —— 索引“快且省内存”的关键之一。
+///
+/// 所有文件名以 UTF-16 连续存放在若干 定长块(每块 1<<20 个字符 = 2MB)里,
+/// 整个索引里不会为文件名产生任何 string 对象;查询时直接在这块内存上取 ReadOnlySpan<char>。
+///
+/// 偏移编码:(chunkIndex << 20) | offsetInChunk,单个 int 即可寻址 2G 字符。
+/// 因为块大小固定为 1<<20,而 NTFS 单个文件名最长 255 字符,
+/// 所以一个名字永远不会跨越两个块 —— 取值时无需拼接。
+///
+/// 线程安全:块数组一次性预分配(只写指针不扩容),块本身写入后用 发布。
+/// 查询线程只会读到“已经完整写入”的块;配合索引条数的发布顺序(先写数据、后写 Count),读侧无需加锁。
+///
+internal sealed class NamePool
+{
+ internal const int ChunkShift = 20;
+ internal const int ChunkSize = 1 << ChunkShift;
+ internal const int ChunkMask = ChunkSize - 1;
+ /// NameRef 只用 8 位存长度,NTFS 文件名最长 255 字符,正好用满。
+ 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();
+ }
+
+ /// 池中已发布的字符总数(约等于所有名字长度之和)。
+ internal int TotalChars => _publishedChars;
+
+ /// 写入一个名字,返回它的池内偏移。空名字返回 0(配合 NameRef 的长度字段仍然无歧义)。
+ internal int Add(ReadOnlySpan 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;
+ }
+
+ /// 取回名字。参数非法时返回空 span(绝不抛异常,读侧可能并发读到正在更新的旧值)。
+ internal ReadOnlySpan 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 打包
+
+ /// 把 (偏移, 长度) 打包进一个 long:低 8 位是长度,高 56 位是偏移。
+ 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);
+}
diff --git a/Services/Search/Usn/UsnNative.cs b/Services/Search/Usn/UsnNative.cs
new file mode 100644
index 0000000..3d6defb
--- /dev/null
+++ b/Services/Search/Usn/UsnNative.cs
@@ -0,0 +1,458 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+
+namespace FluidExplorer.Services.Search.Usn;
+
+///
+/// USN 日志 / NTFS 卷所需的最小 P/Invoke 与结构体集合。
+///
+/// 布局约定:所有 struct 均为 ,默认采用 x64 自然对齐;
+/// 字段顺序与偏移和 Windows SDK 的 winioctl.h / ntifs.h 完全一致。
+/// 每个结构体后面都标注了实测字节大小,便于对照 校验。
+///
+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;
+
+ ///
+ /// 打开 \\.\C: 时用的 dwFlagsAndAttributes。
+ /// 参考实现(Everything)用 FILE_ATTRIBUTE_READONLY:传 FILE_ATTRIBUTE_NORMAL 在部分环境下会开不了卷句柄。
+ ///
+ internal const uint FILE_ATTRIBUTE_READONLY = 0x00000001;
+
+ /// 开目录句柄必须带 FILE_FLAG_BACKUP_SEMANTICS,否则 CreateFile 会失败。
+ internal const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
+
+ /// 顺序扫描提示:读 MFT 时对缓存友好(读一次不再复用)。
+ 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;
+
+ /// FSCTL_DELETE_USN_JOURNAL 的 DeleteFlags:真正删除日志。
+ 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);
+
+ // ================================================================ 结构体
+
+ /// MFT_ENUM_DATA_V0 —— FSCTL_ENUM_USN_DATA 的输入。x64 大小 24。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct MftEnumDataV0
+ {
+ internal ulong StartFileReferenceNumber; // 0
+ internal long LowUsn; // 8 枚举时用 0
+ internal long HighUsn; // 16 枚举时用 long.MaxValue
+ }
+
+ /// USN_JOURNAL_DATA_V0 —— FSCTL_QUERY_USN_JOURNAL 的输出。x64 大小 56。
+ [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
+ }
+
+ /// READ_USN_JOURNAL_DATA_V0 —— FSCTL_READ_USN_JOURNAL 的输入。x64 大小 40。
+ [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
+ }
+
+ ///
+ /// 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,与磁盘上的紧凑布局严格一致。
+ ///
+ [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;
+ }
+
+ /// NTFS_VOLUME_DATA_BUFFER —— FSCTL_GET_NTFS_VOLUME_DATA 的输出。x64 大小 96。
+ [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
+ }
+
+ /// NTFS_FILE_RECORD_INPUT_BUFFER —— FSCTL_GET_NTFS_FILE_RECORD 的输入。x64 大小 8。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct NtfsFileRecordInputBuffer
+ {
+ internal ulong FileReferenceNumber; // 只取低 48 位记录号
+ }
+
+ /// NTFS_FILE_RECORD_OUTPUT_BUFFER 的固定头。x64 大小 12(后面紧跟变长记录)。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct NtfsFileRecordOutputBuffer
+ {
+ internal ulong FileReferenceNumber; // 0
+ internal uint FileRecordLength; // 8
+ }
+
+ /// FSCTL_CREATE_USN_JOURNAL 的输入。x64 大小 16;两个字段都为 0 = 使用系统默认值。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct CreateUsnJournalData
+ {
+ internal ulong MaximumSize; // 0 = 系统默认
+ internal ulong AllocationDelta; // 0 = 系统默认
+ }
+
+ /// FSCTL_DELETE_USN_JOURNAL 的输入。x64 大小 16(ulong + DWORD + 对齐填充)。
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct DeleteUsnJournalData
+ {
+ internal ulong UsnJournalID;
+ internal uint DeleteFlags;
+ }
+
+ ///
+ /// FILETIME 的精确布局:两个 DWORD。
+ /// 刻意不用 long —— 在 LayoutKind.Sequential 下 long 会带来 8 字节对齐,
+ /// 从而把 BY_HANDLE_FILE_INFORMATION 的后续字段全部顶偏。
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct FileTimeValue
+ {
+ internal uint LowDateTime;
+ internal uint HighDateTime;
+
+ internal readonly long ToInt64() => ((long)HighDateTime << 32) | LowDateTime;
+ }
+
+ ///
+ /// BY_HANDLE_FILE_INFORMATION(GetFileInformationByHandle 的输出)。x64 大小 52。
+ /// 其中 FileIndexHigh/Low 合起来就是文件的 64 位 FRN(低 48 位记录号 + 高 16 位序列号),
+ /// 与 USN_RECORD_V2 里的 FileReferenceNumber 口径一致 —— 这是 RDCW 回退路径能定位索引条目的关键。
+ ///
+ [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;
+ }
+
+ /// FILE_NOTIFY_INFORMATION 的固定头(变长文件名紧跟其后,UTF-16,不以 NUL 结尾)。
+ [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);
+
+ ///
+ /// 递归监听目录变化。lpOverlapped == NULL 时是同步阻塞调用(靠 CancelSynchronousIo 打断)。
+ /// 返回 TRUE 且 bytesReturned == 0 表示变更缓冲区溢出(ERROR_NOTIFY_ENUM_DIR),期间的事件已丢失。
+ ///
+ [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);
+
+ // ================================================================ 托管包装
+
+ /// 把读/写缓冲固定后调用 DeviceIoControl;返回 false 时用 取错误码。
+ internal static unsafe bool Ioctl(SafeFileHandle handle, uint code, ReadOnlySpan input, Span 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;
+ }
+ }
+
+ /// 在卷句柄上做一次带偏移的同步读取(卷句柄偏移 = 卷内绝对字节偏移)。
+ internal static unsafe int ReadAt(SafeFileHandle handle, Span 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;
+ }
+ }
+
+ ///
+ /// 按路径取文件的 64 位 FRN 与基本元数据(大小/属性/最后写入时间)。
+ /// 这是 RDCW 回退监听能定位索引条目的基础:句柄上的 FileIndex 与 USN 的 FRN 同口径。
+ /// 只要求 FILE_READ_ATTRIBUTES,普通用户也能用(目录需要 FILE_FLAG_BACKUP_SEMANTICS)。
+ ///
+ 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();
+ }
+ }
+
+ /// 把 Win32 错误码翻译成中文可读信息,供 UI 直接展示。
+ 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 "未知错误";
+ }
+ }
+
+ // ================================================================ 记录号归一化
+
+ /// MFT 记录号掩码:FRN 的低 48 位。
+ internal const ulong RecordNumberMask = 0x0000_FFFF_FFFF_FFFFUL;
+
+ /// 剥掉 FRN 高 16 位的序列号,只保留 MFT 记录号(全索引统一用这个做键)。
+ internal static ulong NormalizeFrn(ulong frn) => frn & RecordNumberMask;
+}
diff --git a/Services/Search/Usn/UsnVolumeIndex.cs b/Services/Search/Usn/UsnVolumeIndex.cs
new file mode 100644
index 0000000..1d1da11
--- /dev/null
+++ b/Services/Search/Usn/UsnVolumeIndex.cs
@@ -0,0 +1,1565 @@
+using System.Buffers.Binary;
+using System.Collections.Concurrent;
+using System.Runtime.InteropServices;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+
+namespace FluidExplorer.Services.Search.Usn;
+
+/// 增量监听实际生效的通道。
+public enum IndexWatchMode
+{
+ /// 未监听。
+ None,
+
+ /// FSCTL_READ_USN_JOURNAL(首选,能拿到 FRN、改名、删除等完整信息)。
+ UsnJournal,
+
+ /// ReadDirectoryChangesW 回退(日志不可用/无权限时;靠路径 → FileIndex 反查 FRN)。
+ DirectoryChanges
+}
+
+///
+/// Everything 式的单卷 NTFS 索引。
+///
+/// 构建:FSCTL_ENUM_USN_DATA 一次性枚举整个 MFT 拿到“全部文件名/父目录/属性”,
+/// 再(若权限允许)直接读原始 $MFT 补齐真实文件大小与精确时间戳。
+/// 监听:FSCTL_READ_USN_JOURNAL 阻塞式循环消费 USN 变更,墓碑标记删除、就地更新改名/大小。
+/// 查询:在 Struct-of-Arrays + 大字符池上做并行扫描,候选零 string 分配,毫秒级返回 Top-N。
+///
+/// 线程模型:
+/// * 快照通过 发布;结构只增不减,读侧无需持锁。
+/// * 追加/扩容走 _gate 串行化(写极少),因此读查询永远不会被写长期阻塞。
+///
+public sealed class UsnVolumeIndex : IFileIndex, IDisposable
+{
+ private const int DefaultMaxResults = 2000;
+
+ ///
+ /// FSCTL_ENUM_USN_DATA 的输出缓冲。参考实现用 0x3900,但更大的缓冲能显著减少往返次数;
+ /// 有些驱动对超大缓冲不友好,1MB 是实践中安全的上限。
+ ///
+ private const int EnumBufferSize = 1024 * 1024;
+ private const int WatchBufferSize = 1024 * 1024;
+ private const long WatchBytesToWaitFor = 64 * 1024;
+ private const int MaxPathDepth = 256;
+ private const int MaxPathCacheEntries = 262144;
+ private const long FileTimeToTicksOffset = 504911232000000000L; // 1601-01-01 → 0001-01-01
+ private const int MaxTrackedRecords = 64 * 1024 * 1024;
+
+ private readonly string _devicePath; // \\.\C:
+ private readonly string _volumeRoot; // C:\
+ private readonly ConcurrentDictionary _dirPathCache = new();
+ private readonly object _gate = new();
+
+ private IndexStore _store = new(1024);
+ private FrnMap _frnMap = new(16);
+
+ private SafeFileHandle? _volumeHandle;
+ private MftReader? _mftReader;
+ private Thread? _watchThread;
+ private IntPtr _watchThreadHandle;
+ private SafeFileHandle? _watchHandle;
+
+ private int _state = (int)IndexState.NotStarted;
+ private long _liveCount;
+ private long _changeCount;
+ private volatile bool _watchRequested;
+ private volatile bool _stopRequested;
+ private DirectoryChangeWatcher? _directoryWatcher;
+ private bool _disposed;
+
+ public UsnVolumeIndex(string volumeRoot)
+ {
+ (_volumeRoot, _devicePath) = NormalizeVolumeRoot(volumeRoot);
+ }
+
+ // ================================================================ IFileIndex
+
+ public string VolumeRoot => _volumeRoot;
+
+ public IndexState State => (IndexState)Volatile.Read(ref _state);
+
+ /// 存活条目数(不含已打墓碑的删除项)。
+ public long EntryCount => Volatile.Read(ref _liveCount);
+
+ /// 为 true 表示大小/时间来自原始 MFT 解析;false 表示 USN 降级( 多为 -1)。
+ public bool UsesRawMft { get; private set; }
+
+ /// 降级或失败的具体原因(中文),便于 UI 直接展示。
+ public string? DegradationReason { get; private set; }
+
+ /// 监听期间累计处理的 USN 变更条数。
+ public long WatchedChangeCount => Volatile.Read(ref _changeCount);
+
+ ///
+ /// 当前实际生效的增量监听通道:优先 USN 变更日志;日志不可用(无权限/不存在且建不了)时回退为
+ /// (ReadDirectoryChangesW,不需要日志权限)。
+ ///
+ public IndexWatchMode WatchMode { get; private set; } = IndexWatchMode.None;
+
+ ///
+ /// 卷上没有 USN 日志时是否允许自动创建(FSCTL_CREATE_USN_JOURNAL,系统默认大小)。
+ /// 默认 true(Everything 的行为);创建属于对卷的持久改动,调用方若不愿改动系统可置 false。
+ /// 无论此值如何,本类从不删除日志。
+ ///
+ public bool CreateJournalIfMissing { get; set; } = true;
+
+ /// 索引构建是否因为找不到 $MFT 而完全没有建立(此时只有回退监听可用)。
+ public bool HasIndex => Volatile.Read(ref _liveCount) > 0 || Volatile.Read(ref _store).Count > 0;
+
+ public event EventHandler? StateChanged;
+
+ ///
+ /// 建立索引。本方法内部使用线程池,绝不阻塞调用线程,也绝不向外抛异常:
+ /// 权限不足 → ;非 NTFS → ;
+ /// 取消 → 。调用方一律通过 / 判断结果。
+ ///
+ public Task BuildAsync(IProgress? progress, CancellationToken cancellationToken)
+ {
+ // 注意:这里传 CancellationToken.None —— 取消由内部协作式处理,避免调用方拿到 Canceled 状态的 Task。
+ return Task.Run(() => BuildCore(progress, cancellationToken), CancellationToken.None);
+ }
+
+ public void StartWatching()
+ {
+ _watchRequested = true;
+ var state = State;
+ // 构建还没结束的话,构建完成后会自动开始监听(见 BuildCore 末尾)
+ if (state is not (IndexState.Ready or IndexState.Watching)) return;
+ StartWatchThread();
+ }
+
+ public void Stop()
+ {
+ // 显式 Stop 之后不再自动开始监听,直到调用方再次 StartWatching()
+ _watchRequested = false;
+ StopWatchingInternal();
+ SetState(IndexState.Stopped, $"{_volumeRoot} 的索引已停止。");
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ StopWatchingInternal();
+ try { _volumeHandle?.Dispose(); } catch (ObjectDisposedException) { }
+ _volumeHandle = null;
+ _mftReader = null;
+ }
+
+ // ================================================================ 构建
+
+ private void BuildCore(IProgress? progress, CancellationToken cancellationToken)
+ {
+ try
+ {
+ StopWatchingInternal();
+ SetState(IndexState.Building, $"正在建立 {_volumeRoot} 的文件索引…");
+ progress?.Report(0d);
+
+ // ---- 1) 打开卷句柄:首选 GENERIC_READ|GENERIC_WRITE,失败后逐级降权 ----
+ var (handle, access, openError) = OpenVolumeWithBestAccess();
+ if (handle is null)
+ {
+ DegradationReason = UsnNative.DescribeError(openError, $"无法打开卷 {_devicePath}");
+ if (openError == UsnNative.ERROR_ACCESS_DENIED)
+ {
+ SetState(IndexState.RequiresElevation,
+ "读取 NTFS 主文件表需要管理员权限,可点击以管理员身份重启索引服务。");
+ }
+ else
+ {
+ // 其它错误(卷未就绪 / 盘符不存在 / 设备被占用…)不是权限问题,别误导用户去提权
+ SetState(IndexState.Failed, $"{_volumeRoot} 索引不可用:{DegradationReason}");
+ }
+ return;
+ }
+
+ SafeFileHandle? old;
+ lock (_gate)
+ {
+ old = _volumeHandle;
+ _volumeHandle = handle;
+ }
+ old?.Dispose();
+
+ bool canReadData = (access & (UsnNative.GENERIC_READ | UsnNative.FILE_READ_DATA)) != 0;
+
+ // ---- 2) 卷信息:同时用来判断是不是 NTFS ----
+ var volumeData = default(UsnNative.NtfsVolumeDataBuffer);
+ bool hasVolumeData = QueryNtfsVolumeData(handle, out volumeData, out var volumeDataError);
+ if (!hasVolumeData)
+ {
+ var fileSystem = TryGetFileSystemName();
+ if (fileSystem is not null && !fileSystem.Equals("NTFS", StringComparison.OrdinalIgnoreCase))
+ {
+ DegradationReason = $"卷 {_volumeRoot} 的文件系统是 {fileSystem},不是 NTFS";
+ SetState(IndexState.Failed, $"{_volumeRoot} 不是 NTFS 卷(检测到 {fileSystem}),Everything 式 USN 索引仅支持 NTFS。");
+ return;
+ }
+
+ // 确实是 NTFS(或无法判定)却拿不到 NTFS 卷结构 —— 实测在非管理员下
+ // FSCTL_GET_NTFS_VOLUME_DATA 会返回 ERROR_INVALID_FUNCTION(1) 而不是 ERROR_ACCESS_DENIED(5),
+ // 所以这里必须按“权限不足”处理,否则会把正常的 NTFS 卷误判成“不是 NTFS”。
+ DegradationReason = UsnNative.DescribeError(volumeDataError, "FSCTL_GET_NTFS_VOLUME_DATA 失败");
+ SetState(IndexState.RequiresElevation,
+ "读取 NTFS 主文件表需要管理员权限,可点击以管理员身份重启索引服务。");
+ return;
+ }
+
+ // ---- 3) USN 变更日志:不存在就尝试创建(创建后绝不自动删除)----
+ bool hasJournal = EnsureUsnJournal(handle, out var journal, out var journalError, out bool journalCreated);
+ if (hasJournal && journalCreated)
+ {
+ SetState(IndexState.Building, "本卷原先没有 USN 变更日志,已按系统默认大小创建以便做增量监听。");
+ }
+ else if (!hasJournal)
+ {
+ DegradationReason ??= UsnNative.DescribeError(journalError, "FSCTL_QUERY_USN_JOURNAL / FSCTL_CREATE_USN_JOURNAL 失败");
+ }
+
+ // ---- 4) 原始 MFT 读取器(需要管理员,成功则大小/时间用真实值)----
+ MftReader? mft = null;
+ string? mftError = null;
+ if (canReadData && hasVolumeData)
+ {
+ mft = MftReader.TryCreate(handle, in volumeData, out mftError);
+ }
+ else if (!canReadData)
+ {
+ mftError = "卷句柄不含 FILE_READ_DATA 权限(非管理员),无法直接读取 $MFT";
+ }
+ else
+ {
+ mftError = "无法获取 NTFS 卷结构信息";
+ }
+
+ UsesRawMft = mft is not null;
+ if (mft is null)
+ {
+ DegradationReason = mftError;
+ }
+
+ // ---- 5) 枚举 MFT ----
+ int estimated = 65536;
+ int bytesPerRecord = 1024;
+ if (hasVolumeData && volumeData.BytesPerFileRecordSegment > 0)
+ {
+ bytesPerRecord = (int)volumeData.BytesPerFileRecordSegment;
+ if (volumeData.MftValidDataLength > 0)
+ {
+ long total = volumeData.MftValidDataLength / bytesPerRecord;
+ if (total > 0 && total <= MaxTrackedRecords) estimated = (int)total;
+ else if (total > MaxTrackedRecords) estimated = MaxTrackedRecords;
+ }
+ }
+
+ var store = new IndexStore(estimated);
+ var map = new FrnMap(Math.Min(estimated, 1 << 20));
+ int live = 0;
+ string? degradedNote = null;
+
+ // 枚举阶段占总进度的 65%,原始 MFT 补齐阶段占 35%
+ double enumWeight = mft is null ? 1.0 : 0.65;
+ long reportTicks = Environment.TickCount64;
+
+ // 输入按参考实现取 [FirstUsn, NextUsn];若一条都没枚举到(日志刚建好、区间过窄等),
+ // 再退一次 [0, MAXLONGLONG] 全量范围 —— 那条路径必然能拿到整个 MFT。
+ long lowUsn = hasJournal ? journal.FirstUsn : 0;
+ long highUsn = hasJournal ? journal.NextUsn : long.MaxValue;
+
+ long visited = EnumMft(handle, lowUsn, highUsn, estimated, cancellationToken, progress, enumWeight, ref reportTicks,
+ (ReadOnlySpan name, ulong frn, ulong parent, uint attributes, long timeStamp) =>
+ {
+ bool isDir = (attributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
+ int idx = AppendEntry(ref store, frn, parent, name, isDir,
+ isDir ? 0 : -1, FileTimeToTicks(timeStamp), attributes);
+ map.AddBuild(frn, idx);
+ live++;
+ });
+
+ if (visited == 0 && (lowUsn != 0 || highUsn != long.MaxValue))
+ {
+ degradedNote = "USN 区间 [FirstUsn, NextUsn] 未枚举到记录,已改用 [0, MAXLONGLONG] 全量枚举";
+ visited = EnumMft(handle, 0, long.MaxValue, estimated, cancellationToken, progress, enumWeight, ref reportTicks,
+ (ReadOnlySpan name, ulong frn, ulong parent, uint attributes, long timeStamp) =>
+ {
+ bool isDir = (attributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
+ int idx = AppendEntry(ref store, frn, parent, name, isDir,
+ isDir ? 0 : -1, FileTimeToTicks(timeStamp), attributes);
+ map.AddBuild(frn, idx);
+ live++;
+ });
+ }
+
+ progress?.Report(enumWeight);
+
+ // ---- 6) 用原始 MFT 给每条记录补齐真实大小/时间/属性 ----
+ if (mft is not null)
+ {
+ long totalBytes = mft.MftValidDataLength;
+ mft.Enumerate(
+ (ulong recordNumber, Span record) =>
+ {
+ if (!MftReader.TryParseRecord(record, mft.BytesPerSector, out var info)) return;
+ if (!info.InUse) return;
+ if (!map.TryGet(recordNumber, out int idx)) return;
+ if ((uint)idx >= (uint)store.Count) return;
+
+ store.ModifiedTicks[idx] = FileTimeToTicks(info.ModifiedFileTime);
+ store.Attributes[idx] = info.Attributes;
+ if (info.IsDirectory) store.Size[idx] = 0;
+ else if (info.Size >= 0) store.Size[idx] = info.Size;
+ },
+ totalBytes,
+ bytesRead =>
+ {
+ long now = Environment.TickCount64;
+ if (now - reportTicks < 60) return;
+ reportTicks = now;
+ double ratio = totalBytes > 0 ? Math.Min(1d, (double)bytesRead / totalBytes) : 0d;
+ progress?.Report(enumWeight + (1d - enumWeight) * ratio);
+ },
+ cancellationToken);
+ }
+
+ // ---- 7) 发布新快照 ----
+ // 注意发布顺序:先写 _frnMap(release),最后才用 volatile 写 _store 作为整批数据的发布点。
+ map.Optimize();
+ lock (_gate)
+ {
+ Volatile.Write(ref _frnMap, map);
+ Volatile.Write(ref _liveCount, live);
+ _mftReader = mft;
+ _changeCount = 0;
+ Volatile.Write(ref _store, store);
+ }
+ _dirPathCache.Clear();
+
+ if (degradedNote is not null)
+ {
+ DegradationReason = DegradationReason is null ? degradedNote : $"{DegradationReason};{degradedNote}";
+ }
+
+ progress?.Report(1d);
+
+ string summary = mft is null
+ ? $"索引完成:{live:N0} 项(USN 降级模式,文件大小未知)。"
+ : $"索引完成:{live:N0} 项(已解析原始 MFT,大小/时间精确)。";
+ SetState(IndexState.Ready, summary);
+
+ // ---- 8) 构建前就调用过 StartWatching() 的话,这里补上 ----
+ // 注意:无论日志是否可用都要起监听线程 —— 线程内部会在日志不可用时回退到 ReadDirectoryChangesW。
+ if (_watchRequested) StartWatchThread();
+ }
+ catch (OperationCanceledException)
+ {
+ SetState(IndexState.Stopped, "索引构建已取消。");
+ }
+ catch (VolumeAccessException ex)
+ {
+ // 卷句柄能打开、但 FSCTL 被拒:同样是权限问题(非管理员下内核返回 INVALID_FUNCTION)
+ DegradationReason = ex.Message;
+ SetState(IndexState.RequiresElevation,
+ "读取 NTFS 主文件表需要管理员权限,可点击以管理员身份重启索引服务。");
+ }
+ catch (Exception ex)
+ {
+ DegradationReason = ex.Message;
+ SetState(IndexState.Failed, $"{_volumeRoot} 索引构建失败:{ex.Message}");
+ }
+ }
+
+ /// FSCTL_ENUM_USN_DATA 主循环:按 StartFileReferenceNumber 递增游标直到 ERROR_HANDLE_EOF。
+ private long EnumMft(
+ SafeFileHandle handle,
+ long lowUsn,
+ long highUsn,
+ int estimatedRecords,
+ CancellationToken cancellationToken,
+ IProgress? progress,
+ double weight,
+ ref long reportTicks,
+ EnumSink sink)
+ {
+ // MFT_ENUM_DATA_V0{ StartFileReferenceNumber = 0, LowUsn = ujd.FirstUsn, HighUsn = ujd.NextUsn }
+ var input = new UsnNative.MftEnumDataV0
+ {
+ StartFileReferenceNumber = 0,
+ LowUsn = lowUsn,
+ HighUsn = highUsn
+ };
+ var inBuffer = new byte[Marshal.SizeOf()];
+ var outBuffer = new byte[EnumBufferSize];
+ long visited = 0;
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ WriteStruct(inBuffer, in input);
+ if (!UsnNative.Ioctl(handle, UsnNative.FSCTL_ENUM_USN_DATA, inBuffer, outBuffer, out int bytesReturned))
+ {
+ int error = Marshal.GetLastWin32Error();
+ // 枚举到尾时内核返回 ERROR_HANDLE_EOF,属于正常结束,不是错误
+ if (error == UsnNative.ERROR_HANDLE_EOF) break;
+ throw new VolumeAccessException(error, UsnNative.DescribeError(error, "FSCTL_ENUM_USN_DATA 枚举 MFT 失败"));
+ }
+
+ // 输出缓冲最开头的 8 字节就是下一页游标(ulong),记录数据从 +8 处开始
+ if (bytesReturned <= 8) break;
+ ulong nextStart = BinaryPrimitives.ReadUInt64LittleEndian(outBuffer);
+ ParseEnumBuffer(outBuffer.AsSpan(8, bytesReturned - 8), sink, ref visited);
+
+ if (nextStart == 0 || nextStart == input.StartFileReferenceNumber) break;
+ input.StartFileReferenceNumber = nextStart;
+
+ long now = Environment.TickCount64;
+ if (now - reportTicks >= 60)
+ {
+ reportTicks = now;
+ double ratio = estimatedRecords > 0 ? Math.Min(1d, (double)visited / estimatedRecords) : 0d;
+ progress?.Report(weight * ratio);
+ }
+ }
+
+ progress?.Report(weight);
+ return visited;
+ }
+
+ /// FSCTL_ENUM_USN_DATA 输出缓冲解析回调(internal 以便被合成数据集测试直接驱动)。
+ internal delegate void EnumSink(ReadOnlySpan name, ulong frn, ulong parent, uint attributes, long timeStamp);
+
+ internal static void ParseEnumBuffer(ReadOnlySpan records, EnumSink sink, ref long visited)
+ {
+ int offset = 0;
+ while (offset + UsnNative.UsnRecordV2.Size <= records.Length)
+ {
+ var header = MemoryMarshal.Read(records[offset..]);
+ int recordLength = (int)header.RecordLength;
+ if (recordLength < UsnNative.UsnRecordV2.Size || offset + recordLength > records.Length) break;
+
+ visited++;
+ // USN_RECORD_V2 之外(V3/V4)在 NTFS 卷上不会出现;这里只处理 V2
+ if (header.MajorVersion == 2
+ && header.FileNameLength > 0
+ && header.FileNameOffset + header.FileNameLength <= recordLength)
+ {
+ var name = MemoryMarshal.Cast(
+ records.Slice(offset + header.FileNameOffset, header.FileNameLength));
+ // 卷根目录的名字是 ".",父目录自指,入索引没有意义
+ if (!(name.Length == 1 && name[0] == '.'))
+ {
+ sink(name,
+ UsnNative.NormalizeFrn(header.FileReferenceNumber),
+ UsnNative.NormalizeFrn(header.ParentFileReferenceNumber),
+ header.FileAttributes,
+ header.TimeStamp);
+ }
+ }
+
+ offset += recordLength;
+ }
+ }
+
+ // ================================================================ 增量监听
+
+ private void StartWatchThread()
+ {
+ lock (_gate)
+ {
+ if (_disposed) return;
+ if (_watchThread is { IsAlive: true }) return;
+ _stopRequested = false;
+ var thread = new Thread(WatchLoop)
+ {
+ IsBackground = true,
+ Name = $"UsnWatch[{_volumeRoot}]",
+ Priority = ThreadPriority.BelowNormal
+ };
+ _watchThread = thread;
+ thread.Start();
+ }
+ }
+
+ ///
+ /// FSCTL_READ_USN_JOURNAL 阻塞式循环。BytesToWaitFor=64KB 让内核攒够一批再唤醒,
+ /// 每次唤醒只处理一个批次,改完一次性触发 StateChanged(Watching)。
+ ///
+ private void WatchLoop()
+ {
+ SafeFileHandle? handle = null;
+ try
+ {
+ _watchThreadHandle = UsnNative.OpenThread(UsnNative.THREAD_TERMINATE, false, UsnNative.GetCurrentThreadId());
+
+ var (opened, _, openError) = OpenVolumeWithBestAccess();
+ handle = opened;
+ bool needFallback;
+ if (handle is null)
+ {
+ ReportWatchIssue($"无法打开卷句柄用于增量监听({UsnNative.DescribeError(openError)}),改用 ReadDirectoryChangesW 回退监听。");
+ needFallback = true;
+ }
+ else
+ {
+ Volatile.Write(ref _watchHandle, handle);
+ // 日志不存在就尝试创建(需要管理员);拿不到就回退
+ if (EnsureUsnJournal(handle, out var journal, out int journalError, out bool created))
+ {
+ if (created)
+ {
+ ReportWatchIssue("本卷原先没有 USN 变更日志,已按系统默认大小创建(不会自动删除)以便做增量监听。");
+ }
+ needFallback = RunUsnJournalLoop(handle, in journal);
+ }
+ else
+ {
+ ReportWatchIssue($"USN 变更日志不可用({UsnNative.DescribeError(journalError)}),改用 ReadDirectoryChangesW 回退监听。");
+ needFallback = true;
+ }
+ }
+
+ if (needFallback && !_stopRequested) RunDirectoryChangesLoop();
+ }
+ catch (Exception ex)
+ {
+ if (!_stopRequested) SetState(IndexState.Ready, $"实时监听异常退出:{ex.Message}");
+ }
+ finally
+ {
+ var watchHandle = Interlocked.Exchange(ref _watchHandle, null);
+ watchHandle?.Dispose();
+ handle?.Dispose();
+ var threadHandle = Interlocked.Exchange(ref _watchThreadHandle, IntPtr.Zero);
+ if (threadHandle != IntPtr.Zero) UsnNative.CloseHandle(threadHandle);
+ }
+ }
+
+ ///
+ /// FSCTL_READ_USN_JOURNAL 阻塞循环。返回 true 表示「USN 通道不可用,请回退到 RDCW」;
+ /// false 表示正常收到停止信号退出。
+ ///
+ private bool RunUsnJournalLoop(SafeFileHandle handle, in UsnNative.UsnJournalDataV0 journal)
+ {
+ var read = new UsnNative.ReadUsnJournalDataV0
+ {
+ StartUsn = journal.NextUsn,
+ ReasonMask = UsnNative.USN_REASON_ANY,
+ ReturnOnlyOnClose = 0,
+ Timeout = 0, // 0 = 无数据时无限等待,靠 CancelSynchronousIo 打断
+ BytesToWaitFor = WatchBytesToWaitFor,
+ UsnJournalID = journal.UsnJournalID
+ };
+
+ var inBuffer = new byte[Marshal.SizeOf()];
+ var outBuffer = new byte[WatchBufferSize];
+ WatchMode = IndexWatchMode.UsnJournal;
+ SetState(IndexState.Watching, $"{_volumeRoot} 索引已就绪,正在通过 USN 日志实时监听文件变更。");
+
+ while (!_stopRequested)
+ {
+ WriteStruct(inBuffer, in read);
+ bool ok = UsnNative.Ioctl(handle, UsnNative.FSCTL_READ_USN_JOURNAL, inBuffer, outBuffer, out int bytesReturned);
+ if (!ok)
+ {
+ int error = Marshal.GetLastWin32Error();
+ if (_stopRequested
+ || error is UsnNative.ERROR_OPERATION_ABORTED or UsnNative.ERROR_CANCELLED or UsnNative.ERROR_INVALID_HANDLE)
+ {
+ return false;
+ }
+ // 日志被删除/重建:重新确保日志可用后继续(期间可能漏掉少量变更,重建索引可修正)
+ if (error is UsnNative.ERROR_JOURNAL_DELETE_IN_PROGRESS
+ or UsnNative.ERROR_JOURNAL_NOT_ACTIVE
+ or UsnNative.ERROR_JOURNAL_ENTRY_DELETED)
+ {
+ if (!EnsureUsnJournal(handle, out var refreshed, out _, out _))
+ {
+ ReportWatchIssue("USN 日志已被删除且无法重新获取,改用 ReadDirectoryChangesW 回退监听。");
+ return true;
+ }
+ read.StartUsn = refreshed.FirstUsn;
+ read.UsnJournalID = refreshed.UsnJournalID;
+ continue;
+ }
+ ReportWatchIssue($"USN 实时监听中断({UsnNative.DescribeError(error)}),改用 ReadDirectoryChangesW 回退监听。");
+ return true;
+ }
+
+ if (bytesReturned <= 8) continue;
+ read.StartUsn = BinaryPrimitives.ReadInt64LittleEndian(outBuffer);
+ int applied = ApplyChanges(outBuffer.AsSpan(8, bytesReturned - 8));
+ if (applied > 0)
+ {
+ Interlocked.Add(ref _changeCount, applied);
+ SetState(IndexState.Watching, $"{_volumeRoot} 索引已更新({applied} 条变更)。");
+ }
+ }
+
+ return false;
+ }
+
+ /// ReadDirectoryChangesW 回退监听(不需要 USN 日志权限)。
+ private void RunDirectoryChangesLoop()
+ {
+ var watcher = new DirectoryChangeWatcher(this);
+ if (!watcher.TryOpen(out var error))
+ {
+ WatchMode = IndexWatchMode.None;
+ SetState(IndexState.Ready, $"实时监听不可用:{error}");
+ return;
+ }
+
+ _directoryWatcher = watcher;
+ WatchMode = IndexWatchMode.DirectoryChanges;
+ SetState(IndexState.Watching,
+ $"{_volumeRoot} 索引已就绪(USN 日志不可用,回退为 ReadDirectoryChangesW 实时监听)。");
+ try
+ {
+ watcher.Run();
+ }
+ finally
+ {
+ _directoryWatcher = null;
+ }
+
+ if (!_stopRequested)
+ {
+ WatchMode = IndexWatchMode.None;
+ SetState(IndexState.Ready, "实时监听已停止。");
+ }
+ }
+
+ /// 应用一批 USN 变更记录,返回处理条数(internal 以便被合成数据集测试直接驱动)。
+ internal int ApplyChanges(ReadOnlySpan records)
+ {
+ int offset = 0;
+ int applied = 0;
+ while (offset + UsnNative.UsnRecordV2.Size <= records.Length)
+ {
+ var header = MemoryMarshal.Read(records[offset..]);
+ int recordLength = (int)header.RecordLength;
+ if (recordLength < UsnNative.UsnRecordV2.Size || offset + recordLength > records.Length) break;
+
+ if (header.MajorVersion == 2)
+ {
+ ReadOnlySpan name = default;
+ if (header.FileNameLength > 0 && header.FileNameOffset + header.FileNameLength <= recordLength)
+ {
+ name = MemoryMarshal.Cast(
+ records.Slice(offset + header.FileNameOffset, header.FileNameLength));
+ }
+ ApplyOne(in header, name);
+ applied++;
+ }
+ offset += recordLength;
+ }
+ return applied;
+ }
+
+ private void ApplyOne(in UsnNative.UsnRecordV2 header, ReadOnlySpan name)
+ {
+ ulong frn = UsnNative.NormalizeFrn(header.FileReferenceNumber);
+ ulong parent = UsnNative.NormalizeFrn(header.ParentFileReferenceNumber);
+ uint reason = header.Reason;
+ var store = Volatile.Read(ref _store);
+ bool exists = _frnMap.TryGet(frn, out int index) && (uint)index < (uint)store.Count;
+
+ // ---- 删除:打墓碑,不搬移数组 ----
+ if ((reason & UsnNative.USN_REASON_FILE_DELETE) != 0)
+ {
+ if (exists && (store.Flags[index] & IndexStore.FlagDeleted) == 0)
+ {
+ store.Flags[index] |= IndexStore.FlagDeleted;
+ Interlocked.Decrement(ref _liveCount);
+ }
+ return;
+ }
+
+ bool isDir = (header.FileAttributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
+
+ if (!exists)
+ {
+ if (name.Length == 0) return;
+ index = AppendEntryThreadSafe(frn, parent, name, isDir,
+ isDir ? 0 : -1, FileTimeToTicks(header.TimeStamp), header.FileAttributes);
+ Interlocked.Increment(ref _liveCount);
+ RefreshFromDisk(frn, index);
+ return;
+ }
+
+ // ---- 改名 / 移动:就地改名字引用与父目录(NameRef 单次 8 字节原子写,读侧不会看到半新半旧)----
+ if (name.Length > 0)
+ {
+ var current = store.GetName(index);
+ if (!current.SequenceEqual(name))
+ {
+ if (name.Length > NamePool.MaxNameLength) name = name[..NamePool.MaxNameLength];
+ int nameOffset = store.Names.Add(name);
+ Volatile.Write(ref store.NameRef[index], NamePool.Pack(nameOffset, name.Length));
+ store.ParentFrn[index] = parent;
+ }
+ else if (store.ParentFrn[index] != parent)
+ {
+ store.ParentFrn[index] = parent;
+ }
+ }
+
+ // 复活:之前被标记删除的记录号又被新文件复用了
+ if ((store.Flags[index] & IndexStore.FlagDeleted) != 0 && (reason & UsnNative.USN_REASON_FILE_CREATE) != 0)
+ {
+ store.Flags[index] &= unchecked((byte)~IndexStore.FlagDeleted);
+ Interlocked.Increment(ref _liveCount);
+ }
+
+ RefreshFromDisk(frn, index);
+ }
+
+ /// 用原始 MFT 刷新单个条目的大小/时间/属性;没有 MFT 时退化为用 USN 的变更时间。
+ private void RefreshFromDisk(ulong frn, int index)
+ {
+ var store = Volatile.Read(ref _store);
+ if ((uint)index >= (uint)store.Count) return;
+
+ var mft = _mftReader;
+ if (mft is null) return; // USN 降级模式:大小保持 -1(未知),时间已在创建/改名时写入
+
+ Span buffer = mft.BytesPerRecord <= 4096 ? stackalloc byte[4096] : new byte[mft.BytesPerRecord];
+ if (!mft.TryReadRecord(frn, buffer)) return;
+ if (!MftReader.TryParseRecord(buffer, mft.BytesPerSector, out var info)) return;
+
+ if (info.IsDirectory) store.Size[index] = 0;
+ else if (info.Size >= 0) store.Size[index] = info.Size;
+ if (info.ModifiedFileTime > 0) store.ModifiedTicks[index] = FileTimeToTicks(info.ModifiedFileTime);
+ if (info.Attributes != 0) store.Attributes[index] = info.Attributes;
+ }
+
+ // ================================================================ RDCW 回退通道的落地接口
+
+ ///
+ /// 由 调用:按 FRN 写入/更新一条记录(RDCW 只有路径,FRN 来自句柄)。
+ ///
+ internal void UpsertFromFileSystem(ulong frn, ulong parentFrn, ReadOnlySpan name,
+ bool isDirectory, long size, long modifiedTicks, uint attributes)
+ {
+ frn = UsnNative.NormalizeFrn(frn);
+ parentFrn = UsnNative.NormalizeFrn(parentFrn);
+
+ var store = Volatile.Read(ref _store);
+ if (_frnMap.TryGet(frn, out int index) && (uint)index < (uint)store.Count)
+ {
+ if (name.Length > 0)
+ {
+ var current = store.GetName(index);
+ if (!current.SequenceEqual(name))
+ {
+ if (name.Length > NamePool.MaxNameLength) name = name[..NamePool.MaxNameLength];
+ int nameOffset = store.Names.Add(name);
+ Volatile.Write(ref store.NameRef[index], NamePool.Pack(nameOffset, name.Length));
+ }
+ if (parentFrn != 0) store.ParentFrn[index] = parentFrn;
+ }
+ store.Size[index] = isDirectory ? 0 : size;
+ if (modifiedTicks > 0) store.ModifiedTicks[index] = modifiedTicks;
+ if (attributes != 0) store.Attributes[index] = attributes;
+ store.Flags[index] = (byte)((store.Flags[index] & IndexStore.FlagDeleted)
+ | (isDirectory ? IndexStore.FlagDirectory : 0));
+
+ // 记录号被复用:之前打的墓碑要撤掉
+ if ((store.Flags[index] & IndexStore.FlagDeleted) != 0)
+ {
+ store.Flags[index] &= unchecked((byte)~IndexStore.FlagDeleted);
+ Interlocked.Increment(ref _liveCount);
+ }
+ Interlocked.Increment(ref _changeCount);
+ return;
+ }
+
+ if (name.Length == 0) return;
+ AppendEntryThreadSafe(frn, parentFrn, name, isDirectory, isDirectory ? 0 : size, modifiedTicks, attributes);
+ Interlocked.Increment(ref _liveCount);
+ Interlocked.Increment(ref _changeCount);
+ }
+
+ ///
+ /// 由 调用:对某个目录做「磁盘现状 vs 索引」对账,
+ /// 把索引里存在、磁盘上已消失的孩子打上墓碑。返回打墓碑的条数。
+ /// RDCW 的删除事件只给路径、无法反查 FRN,所以删除统一走这条对账路径。
+ ///
+ internal int ResyncDirectoryFromDisk(string directoryPath)
+ {
+ if (!UsnNative.TryStatPath(directoryPath, out var directoryInfo)) return 0;
+ ulong directoryFrn = UsnNative.NormalizeFrn(directoryInfo.FileIndex);
+ if (directoryFrn == 0) return 0;
+
+ HashSet liveNames;
+ try
+ {
+ liveNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in Directory.EnumerateFileSystemEntries(directoryPath))
+ {
+ var leaf = Path.GetFileName(entry);
+ if (leaf.Length > 0) liveNames.Add(leaf);
+ }
+ }
+ catch (IOException)
+ {
+ return 0;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return 0;
+ }
+
+ var store = Volatile.Read(ref _store);
+ int tombstoned = 0;
+ for (int i = 0; i < store.Count; i++)
+ {
+ if ((store.Flags[i] & IndexStore.FlagDeleted) != 0) continue;
+ if (store.ParentFrn[i] != directoryFrn) continue;
+
+ var name = store.GetName(i);
+ if (name.Length == 0) continue;
+ if (liveNames.Contains(name.ToString())) continue;
+
+ store.Flags[i] |= IndexStore.FlagDeleted;
+ Interlocked.Decrement(ref _liveCount);
+ Interlocked.Increment(ref _changeCount);
+ tombstoned++;
+ }
+ return tombstoned;
+ }
+
+ /// 把监听通道的异常/进展以 StateChanged(Watching) 的形式抛给 UI。
+ internal void ReportWatchIssue(string? message, int tombstoned = 0)
+ {
+ if (message is null && tombstoned <= 0) return;
+ var text = message is null
+ ? $"{_volumeRoot} 索引已更新({tombstoned} 项删除)。"
+ : tombstoned > 0
+ ? $"{message}({tombstoned} 项删除)"
+ : message;
+ SetState(WatchMode == IndexWatchMode.None ? IndexState.Ready : IndexState.Watching, text);
+ }
+
+ private void StopWatchingInternal() {
+ _stopRequested = true;
+ Thread? thread;
+ lock (_gate)
+ {
+ thread = _watchThread;
+ _watchThread = null;
+ }
+
+ var threadHandle = _watchThreadHandle;
+ if (threadHandle != IntPtr.Zero) UsnNative.CancelSynchronousIo(threadHandle);
+ // 回退通道用的是目录句柄上的 ReadDirectoryChangesW,同样用 CancelSynchronousIo 打断,外加关句柄兜底
+ _directoryWatcher?.RequestStop();
+
+ if (thread is { IsAlive: true })
+ {
+ if (!thread.Join(1500))
+ {
+ // 兜底:CancelSynchronousIo 没生效时直接关掉卷句柄,让阻塞中的 DeviceIoControl 立刻失败返回
+ var watchHandle = Interlocked.Exchange(ref _watchHandle, null);
+ watchHandle?.Dispose();
+ UsnNative.CancelSynchronousIo(threadHandle);
+ thread.Join(1500);
+ }
+ }
+ }
+
+ // ================================================================ 查询
+
+ public IEnumerable Query(SearchQuery query, int maxResults, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ var store = Volatile.Read(ref _store);
+ int count = store.Count;
+ int k = maxResults > 0 ? maxResults : DefaultMaxResults;
+
+ if (count == 0) return [];
+
+ var spec = new QuerySpec(query);
+
+ // 并行分片:分片数按 CPU 核数放大,同时限制每片 Top-K 堆的总内存
+ int partitions = Math.Min(count, Math.Max(1, Environment.ProcessorCount * 4));
+ if (partitions > 1 && (long)k * partitions > 4_000_000L)
+ {
+ partitions = (int)Math.Max(1, 4_000_000L / k);
+ }
+ int chunk = (count + partitions - 1) / partitions;
+
+ var partials = new TopK?[partitions];
+ var options = new ParallelOptions
+ {
+ CancellationToken = cancellationToken,
+ MaxDegreeOfParallelism = Environment.ProcessorCount
+ };
+
+ Parallel.For(0, partitions, options, p =>
+ {
+ int start = p * chunk;
+ int end = Math.Min(count, start + chunk);
+ if (start >= end) return;
+ var top = new TopK(k);
+ for (int i = start; i < end; i++)
+ {
+ long key = Probe(store, i, in spec);
+ if (key >= 0) top.Add(key, i);
+ }
+ partials[p] = top;
+ });
+
+ var merged = new TopK(k);
+ foreach (var partial in partials)
+ {
+ partial?.DrainInto(merged);
+ }
+
+ var hits = merged.ToSortedArray();
+ var results = new List(hits.Length);
+ foreach (var (key, index) in hits)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var entry = Materialize(store, index);
+ if (entry is not null) results.Add(entry.Value);
+ }
+ return results;
+ }
+
+ ///
+ /// 单条候选的过滤 + 打分。返回 >=0 的排序键(越小越好),-1 表示被过滤掉。
+ /// 过滤顺序刻意从最便宜的条件到最贵的:先看标志位/大小/时间,再做名字匹配,最后才解析路径。
+ ///
+ private long Probe(IndexStore store, int i, in QuerySpec spec)
+ {
+ byte flags = store.Flags[i];
+ if ((flags & IndexStore.FlagDeleted) != 0) return -1;
+
+ bool isDir = (flags & IndexStore.FlagDirectory) != 0;
+ if (spec.DirectoriesOnly && !isDir) return -1;
+ if (spec.FilesOnly && isDir) return -1;
+
+ long size = store.Size[i];
+ if (size >= 0)
+ {
+ // 注意:Size < 0 表示“未知”(USN 降级),必须放行,绝不能当成 0 字节
+ if (spec.MinSize != long.MinValue && size < spec.MinSize) return -1;
+ if (spec.MaxSize != long.MaxValue && size > spec.MaxSize) return -1;
+ }
+
+ long ticks = store.ModifiedTicks[i];
+ if (ticks > 0)
+ {
+ if (spec.ModifiedAfterTicks > 0 && ticks < spec.ModifiedAfterTicks) return -1;
+ if (spec.ModifiedBeforeTicks > 0 && ticks > spec.ModifiedBeforeTicks) return -1;
+ }
+
+ var name = store.GetName(i);
+
+ if (spec.Extensions.Length > 0 && !MatchExtension(name, spec.Extensions)) return -1;
+ if (spec.ExcludeExtensions.Length > 0 && MatchExtension(name, spec.ExcludeExtensions)) return -1;
+
+ string? path = null;
+ if (!MatchNameAll(name, in spec, out int rank))
+ {
+ // 名字没全中:只有允许整路径匹配时才付出解析路径的代价
+ if (!spec.MatchWholePath) return -1;
+ path = ResolveFullPath(i);
+ if (path is null || !MatchNameAll(path.AsSpan(), in spec, out rank)) return -1;
+ }
+
+ if (spec.PathFilter is not null)
+ {
+ path ??= ResolveFullPath(i);
+ if (path is null) return -1;
+ if (path.IndexOf(spec.PathFilter, StringComparison.OrdinalIgnoreCase) < 0) return -1;
+ }
+
+ return PackKey(rank, name.Length, i);
+ }
+
+ private static long PackKey(int rank, int nameLength, int index)
+ => ((long)rank << 56) | ((long)Math.Min(nameLength, 0xFFFFFF) << 32) | (uint)index;
+
+ /// IncludeTerms / IncludeRegexLike 全部命中才算通过;rank 取最差的一档。
+ private static bool MatchNameAll(ReadOnlySpan name, in QuerySpec spec, out int rank)
+ {
+ rank = 0;
+ var terms = spec.IncludeTerms;
+ for (int t = 0; t < terms.Length; t++)
+ {
+ int r = MatchLiteral(name, terms[t]);
+ if (r < 0) return false;
+ if (r > rank) rank = r;
+ }
+
+ var patterns = spec.Wildcards;
+ for (int t = 0; t < patterns.Length; t++)
+ {
+ var pattern = patterns[t];
+ if (!WildcardMatcher.IsMatch(name, pattern)) return false;
+ int r = WildcardRank(name, pattern);
+ if (r > rank) rank = r;
+ }
+
+ var excludes = spec.ExcludeTerms;
+ for (int t = 0; t < excludes.Length; t++)
+ {
+ if (name.IndexOf(excludes[t], StringComparison.OrdinalIgnoreCase) >= 0) return false;
+ }
+
+ return true;
+ }
+
+ /// 字面量子串匹配并给出优先级:完全相等(0) > 前缀(1) > 词边界(2) > 普通子串(3)。
+ private static int MatchLiteral(ReadOnlySpan name, string term)
+ {
+ int idx = name.IndexOf(term, StringComparison.OrdinalIgnoreCase);
+ if (idx < 0) return -1;
+ if (idx != 0) return !char.IsLetterOrDigit(name[idx - 1]) ? 2 : 3;
+ return name.Length == term.Length ? 0 : 1;
+ }
+
+ private static int WildcardRank(ReadOnlySpan name, ReadOnlySpan pattern)
+ {
+ var prefix = WildcardMatcher.LiteralPrefix(pattern);
+ if (prefix.Length == 0) return 3;
+ if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return 3;
+ return name.Length == prefix.Length ? 0 : 1;
+ }
+
+ /// 扩展名比较:忽略大小写,且允许查询侧不带点("log" 与 ".LOG" 都算命中)。
+ private static bool MatchExtension(ReadOnlySpan name, string[] extensions)
+ {
+ int dot = name.LastIndexOf('.');
+ if (dot <= 0 || dot == name.Length - 1) return false;
+ var extension = name[(dot + 1)..];
+ foreach (var candidate in extensions)
+ {
+ if (extension.Equals(candidate, StringComparison.OrdinalIgnoreCase)) return true;
+ }
+ return false;
+ }
+
+ private IndexedEntry? Materialize(IndexStore store, int index)
+ {
+ if ((uint)index >= (uint)store.Count) return null;
+ long nameRef = Volatile.Read(ref store.NameRef[index]);
+ var nameSpan = store.Names.Get(NamePool.UnpackOffset(nameRef), NamePool.UnpackLength(nameRef));
+ if (nameSpan.Length == 0) return null;
+
+ bool isDir = (store.Flags[index] & IndexStore.FlagDirectory) != 0;
+ long ticks = store.ModifiedTicks[index];
+ var modified = ticks > 0 ? new DateTime(ticks, DateTimeKind.Utc) : default;
+
+ return new IndexedEntry(
+ store.Frn[index],
+ store.ParentFrn[index],
+ nameSpan.ToString(),
+ isDir,
+ store.Size[index],
+ modified,
+ store.Attributes[index])
+ {
+ FullPath = ResolveFullPath(index)
+ };
+ }
+
+ // ================================================================ 路径解析
+
+ public bool TryResolvePath(ulong frn, out string fullPath)
+ {
+ fullPath = string.Empty;
+ if (!_frnMap.TryGet(frn, out int index)) return false;
+ var store = Volatile.Read(ref _store);
+ if ((uint)index >= (uint)store.Count) return false;
+ var path = ResolveFullPath(index);
+ if (path is null) return false;
+ fullPath = path;
+ return true;
+ }
+
+ /// 条目 → 完整路径。父链缺失或 FRN==0 时以 终止;彻底失败返回 null。
+ private string? ResolveFullPath(int index)
+ {
+ var store = Volatile.Read(ref _store);
+ if ((uint)index >= (uint)store.Count) return null;
+
+ bool isDir = (store.Flags[index] & IndexStore.FlagDirectory) != 0;
+ if (isDir) return ResolveDirectoryPath(index);
+
+ var name = store.GetName(index);
+ if (name.Length == 0) return null;
+
+ ulong frn = store.Frn[index];
+ ulong parent = store.ParentFrn[index];
+ if (parent == 0 || parent == frn) return string.Concat(VolumeRoot, name);
+ if (!_frnMap.TryGet(parent, out int parentIndex) || (uint)parentIndex >= (uint)store.Count)
+ {
+ return string.Concat(VolumeRoot, name);
+ }
+
+ var directory = ResolveDirectoryPath(parentIndex);
+ return directory is null ? null : string.Concat(directory, name);
+ }
+
+ ///
+ /// 目录条目 → 以 '\' 结尾的完整路径。父链结果整体缓存(键是目录 FRN),
+ /// 上层目录一旦算过,子目录拼一次字符串即可 —— 这是路径过滤能保持毫秒级的关键。
+ ///
+ private string? ResolveDirectoryPath(int index)
+ {
+ var store = Volatile.Read(ref _store);
+ if ((uint)index >= (uint)store.Count) return null;
+
+ ulong startFrn = store.Frn[index];
+ if (_dirPathCache.TryGetValue(startFrn, out var cached))
+ {
+ return cached.Length == 0 ? null : cached;
+ }
+
+ List? chain = null;
+ string? prefix = null;
+ int current = index;
+
+ for (int depth = 0; depth < MaxPathDepth; depth++)
+ {
+ ulong frn = store.Frn[current];
+ ulong parent = store.ParentFrn[current];
+
+ // 终止条件(三条,都不硬编码根 FRN):
+ // (a) 父指向自身 —— NTFS 卷根(MFT 记录 5)的特征;
+ // (b) parent == 0(没有父);
+ // (c) 父不在映射里(父已被删/记录号已复用)。
+ // 命中任一条就以卷符为前缀结束,并且当前这一层(卷根自己)不写进路径。
+ if (parent == 0 || parent == frn)
+ {
+ prefix = VolumeRoot;
+ break;
+ }
+ (chain ??= new List(8)).Add(current);
+
+ if (_dirPathCache.TryGetValue(parent, out var parentPath))
+ {
+ if (parentPath.Length == 0)
+ {
+ CacheDirectoryPath(startFrn, string.Empty);
+ return null;
+ }
+ prefix = parentPath;
+ break;
+ }
+ if (!_frnMap.TryGet(parent, out int parentIndex) || (uint)parentIndex >= (uint)store.Count)
+ {
+ prefix = VolumeRoot; // 父项缺失 → 以卷根终止
+ break;
+ }
+ current = parentIndex;
+ }
+
+ if (prefix is null)
+ {
+ // 超过深度上限:父链成环或数据损坏
+ CacheDirectoryPath(startFrn, string.Empty);
+ return null;
+ }
+
+ var builder = new StringBuilder(prefix, prefix.Length + chain!.Count * 16);
+ for (int i = chain.Count - 1; i >= 0; i--)
+ {
+ var segment = store.GetName(chain[i]);
+ if (segment.Length == 0) continue;
+ // 卷根目录在枚举里可能叫 "."(也可能是卷标),两种都不应该出现在路径里
+ if (segment.Length == 1 && segment[0] == '.') continue;
+ if (segment.Length == 2 && segment[0] == '.' && segment[1] == '.') continue;
+ builder.Append(segment).Append('\\');
+ }
+
+ var path = builder.ToString();
+ CacheDirectoryPath(startFrn, path);
+ return path;
+ }
+
+ private void CacheDirectoryPath(ulong frn, string path)
+ {
+ if (_dirPathCache.Count >= MaxPathCacheEntries) _dirPathCache.Clear();
+ _dirPathCache[frn] = path;
+ }
+
+ // ================================================================ 卷 / 日志
+
+ ///
+ /// 打开卷句柄。
+ ///
+ /// 首选参数严格按参考实现(Everything)来:
+ /// CreateFileW(@"\\.\C:", GENERIC_READ|GENERIC_WRITE,
+ /// FILE_SHARE_READ|FILE_SHARE_WRITE, null, OPEN_EXISTING, FILE_ATTRIBUTE_READONLY, null)
+ /// —— 共享模式必须带 FILE_SHARE_WRITE;dwFlagsAndAttributes 用 FILE_ATTRIBUTE_READONLY(NORMAL 反而可能开不了)。
+ ///
+ /// 之后逐级降权重试是为了「非管理员也能跑」:普通用户开不了读写卷句柄,
+ /// 但常常仍能拿到 FILE_READ_ATTRIBUTES 的句柄,从而完成 MFT 枚举(只是读不了原始数据 → 大小未知)。
+ ///
+ private (SafeFileHandle? Handle, uint Access, int Error) OpenVolumeWithBestAccess()
+ {
+ ReadOnlySpan accesses =
+ [
+ UsnNative.GENERIC_READ | UsnNative.GENERIC_WRITE,
+ UsnNative.GENERIC_READ,
+ UsnNative.FILE_READ_DATA | UsnNative.FILE_READ_ATTRIBUTES,
+ UsnNative.FILE_READ_ATTRIBUTES,
+ 0
+ ];
+
+ const uint share = UsnNative.FILE_SHARE_READ | UsnNative.FILE_SHARE_WRITE | UsnNative.FILE_SHARE_DELETE;
+ const uint flags = UsnNative.FILE_ATTRIBUTE_READONLY;
+
+ int lastError = 0;
+ foreach (var access in accesses)
+ {
+ var handle = UsnNative.CreateFileW(
+ _devicePath, access, share, IntPtr.Zero, UsnNative.OPEN_EXISTING, flags, IntPtr.Zero);
+
+ if (!handle.IsInvalid) return (handle, access, 0);
+ lastError = Marshal.GetLastWin32Error();
+ handle.Dispose();
+ }
+
+ return (null, 0, lastError);
+ }
+
+ private static bool QueryNtfsVolumeData(SafeFileHandle handle, out UsnNative.NtfsVolumeDataBuffer data, out int error)
+ {
+ data = default;
+ var buffer = new byte[Marshal.SizeOf()];
+ if (UsnNative.Ioctl(handle, UsnNative.FSCTL_GET_NTFS_VOLUME_DATA, [], buffer, out int bytes) && bytes >= buffer.Length)
+ {
+ data = MemoryMarshal.Read(buffer);
+ error = 0;
+ return true;
+ }
+ error = Marshal.GetLastWin32Error();
+ return false;
+ }
+
+ private static bool QueryUsnJournal(SafeFileHandle handle, out UsnNative.UsnJournalDataV0 journal, out int error)
+ {
+ journal = default;
+ var buffer = new byte[Marshal.SizeOf()];
+ if (UsnNative.Ioctl(handle, UsnNative.FSCTL_QUERY_USN_JOURNAL, [], buffer, out int bytes) && bytes >= buffer.Length)
+ {
+ journal = MemoryMarshal.Read(buffer);
+ error = 0;
+ return journal.UsnJournalID != 0;
+ }
+ error = Marshal.GetLastWin32Error();
+ return false;
+ }
+
+ ///