commit 5780fde61a2314d410cb77fbb6444a46527f4f27 Author: WpyQwq <3911625973@qq.com> Date: Sat Sep 19 11:54:03 2026 +0800 Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑) 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; + } + + /// + /// 确保卷上有 USN 变更日志可用:先 QUERY,拿不到(ERROR_JOURNAL_NOT_ACTIVE / ERROR_INVALID_FUNCTION 等) + /// 就尝试 FSCTL_CREATE_USN_JOURNAL 创建(MaximumSize/AllocationDelta 传 0 = 用系统默认值)。 + /// + /// 重要约定:**创建后绝不自动删除**(删掉就再也没法做增量监听了)。 + /// 只有调用方显式调用 才会删,且 DeleteFlags 用 USN_DELETE_FLAG_DELETE。 + /// + private bool EnsureUsnJournal(SafeFileHandle handle, out UsnNative.UsnJournalDataV0 journal, out int error, out bool created) + { + created = false; + if (QueryUsnJournal(handle, out journal, out error)) return true; + if (!CreateJournalIfMissing) return false; + + // 创建日志是需要管理员权限的操作;失败时把错误码原样带回给调用方分类 + var input = new byte[Marshal.SizeOf()]; + WriteStruct(input, new UsnNative.CreateUsnJournalData { MaximumSize = 0, AllocationDelta = 0 }); + var output = new byte[16]; // 该 FSCTL 无输出,给个非空缓冲更保守 + if (!UsnNative.Ioctl(handle, UsnNative.FSCTL_CREATE_USN_JOURNAL, input, output, out _)) + { + error = Marshal.GetLastWin32Error(); + return false; + } + + created = true; + return QueryUsnJournal(handle, out journal, out error); + } + + /// + /// 显式删除本卷的 USN 变更日志(DeleteFlags = USN_DELETE_FLAG_DELETE)。 + /// 只有「用户主动关闭本卷索引并愿意放弃增量能力」时才调用;内部任何流程都不会自动调用它。 + /// + public bool DeleteUsnJournal() + { + var (handle, _, _) = OpenVolumeWithBestAccess(); + if (handle is null) return false; + try + { + if (!QueryUsnJournal(handle, out var journal, out _)) return false; + var input = new byte[Marshal.SizeOf()]; + WriteStruct(input, new UsnNative.DeleteUsnJournalData + { + UsnJournalID = journal.UsnJournalID, + DeleteFlags = UsnNative.USN_DELETE_FLAG_DELETE + }); + var output = new byte[16]; + return UsnNative.Ioctl(handle, UsnNative.FSCTL_DELETE_USN_JOURNAL, input, output, out _); + } + finally + { + handle.Dispose(); + } + } + + /// 通过 FSCTL_GET_NTFS_FILE_RECORD 直接取一条 MFT 记录(备用通道,不依赖 ReadFile 权限)。 + internal static bool TryGetNtfsFileRecord(SafeFileHandle handle, ulong recordNumber, byte[] buffer, out int recordLength) + { + recordLength = 0; + var input = new byte[Marshal.SizeOf()]; + WriteStruct(input, new UsnNative.NtfsFileRecordInputBuffer + { + FileReferenceNumber = UsnNative.NormalizeFrn(recordNumber) + }); + if (!UsnNative.Ioctl(handle, UsnNative.FSCTL_GET_NTFS_FILE_RECORD, input, buffer, out int bytes)) return false; + if (bytes < Marshal.SizeOf()) return false; + recordLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(8)); + return recordLength > 0; + } + + // ================================================================ 工具 + + private static unsafe void WriteStruct(byte[] buffer, in T value) where T : unmanaged + { + fixed (byte* p = buffer) + { + *(T*)p = value; + } + } + + /// + /// 追加一条记录;容量不足时 copy-on-write 扩容。返回新下标(扩容后 store 引用可能被替换,故用 ref)。 + /// 名字池始终取自 store.Names —— 绝不能让“写名字的池”和“快照的池”变成两个对象。 + /// + private static int AppendEntry(ref IndexStore store, ulong frn, ulong parent, + ReadOnlySpan name, bool isDirectory, long size, long modifiedTicks, uint attributes) + { + if (store.Count >= store.Capacity) store = IndexStore.Grow(store, store.Count + 1); + if (name.Length > NamePool.MaxNameLength) name = name[..NamePool.MaxNameLength]; + + int index = store.Count; + int nameOffset = store.Names.Add(name); + + store.Frn[index] = frn; + store.ParentFrn[index] = parent; + store.NameRef[index] = NamePool.Pack(nameOffset, name.Length); + store.Size[index] = size; + store.ModifiedTicks[index] = modifiedTicks; + store.Attributes[index] = attributes; + store.Flags[index] = isDirectory ? IndexStore.FlagDirectory : (byte)0; + + // 最后才发布条数:保证读侧看到的下标一定已经写满字段 + store.Count = index + 1; + return index; + } + + private int AppendEntryThreadSafe(ulong frn, ulong parent, ReadOnlySpan name, + bool isDirectory, long size, long modifiedTicks, uint attributes) + { + lock (_gate) + { + var store = Volatile.Read(ref _store); + int index = AppendEntry(ref store, frn, parent, name, isDirectory, size, modifiedTicks, attributes); + Volatile.Write(ref _store, store); + _frnMap.Set(frn, index); + return index; + } + } + + private void SetState(IndexState state, string? message = null) + { + Volatile.Write(ref _state, (int)state); + var handler = StateChanged; + if (handler is null) return; + try + { + handler(this, new IndexStateChangedEventArgs(state, message)); + } + catch + { + // UI 回调里的异常绝不能影响索引线程 + } + } + + /// FILETIME(1601-01-01 起) → .NET ticks(0001-01-01 起);非法值一律变成 0(= 未知)。 + internal static long FileTimeToTicks(long fileTime) + { + if (fileTime <= 0) return 0; + long ticks = fileTime + FileTimeToTicksOffset; + if (ticks < 0 || ticks > DateTime.MaxValue.Ticks) return 0; + return ticks; + } + + /// 查询侧时间边界 → UTC ticks(SearchQuery 里的日期都是本地时间语义)。 + private static long ToUtcTicks(DateTime? value) + { + if (value is not { } date) return 0; + return date.Kind == DateTimeKind.Utc ? date.Ticks : date.ToUniversalTime().Ticks; + } + + /// + /// 不带任何权限地用 DriveInfo 读出文件系统名("NTFS"/"FAT32"/"exFAT"…)。 + /// 关键作用:把“不是 NTFS”和“没有权限”这两种都会返回 ERROR_INVALID_FUNCTION 的情况区分开。 + /// + private string? TryGetFileSystemName() + { + try + { + if (_volumeRoot.Length >= 2 && _volumeRoot[1] == ':') return new DriveInfo(_volumeRoot).DriveFormat; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + return null; + } + + private static (string Root, string Device) NormalizeVolumeRoot(string volumeRoot) { + ArgumentException.ThrowIfNullOrWhiteSpace(volumeRoot); + var text = volumeRoot.Trim(); + + // \\?\Volume{GUID}\ 形式:直接用它当设备路径 + if (text.StartsWith(@"\\?\", StringComparison.Ordinal) || text.StartsWith(@"\\.\", StringComparison.Ordinal)) + { + var device = text.TrimEnd('\\'); + return (device + "\\", device); + } + + var letter = char.ToUpperInvariant(text[0]); + if (letter is < 'A' or > 'Z') + { + throw new ArgumentException($"卷根必须是盘符形式(如 \"C:\\\")或 \\\\?\\Volume{{GUID}}\\,收到:{volumeRoot}", nameof(volumeRoot)); + } + return ($"{letter}:\\", $@"\\.\{letter}:"); + } + + // ================================================================ 查询预处理 / Top-K + + /// 把 SearchQuery 里每轮循环都要读的字段摊平,避免热路径上反复做属性/接口调用。 + private readonly struct QuerySpec + { + internal readonly string[] IncludeTerms; + internal readonly string[] ExcludeTerms; + internal readonly string[] Wildcards; + internal readonly string[] Extensions; + internal readonly string[] ExcludeExtensions; + internal readonly string? PathFilter; + internal readonly bool MatchWholePath; + internal readonly bool DirectoriesOnly; + internal readonly bool FilesOnly; + internal readonly long MinSize; + internal readonly long MaxSize; + internal readonly long ModifiedAfterTicks; + internal readonly long ModifiedBeforeTicks; + + internal QuerySpec(SearchQuery query) + { + IncludeTerms = [.. query.IncludeTerms]; + ExcludeTerms = [.. query.ExcludeTerms]; + Wildcards = [.. query.IncludeRegexLike]; + Extensions = [.. query.Extensions]; + ExcludeExtensions = [.. query.ExcludeExtensions]; + PathFilter = string.IsNullOrEmpty(query.PathFilter) ? null : query.PathFilter; + MatchWholePath = query.MatchWholePath || PathFilter is not null; + DirectoriesOnly = query.DirectoriesOnly; + FilesOnly = query.FilesOnly; + MinSize = query.MinSize ?? long.MinValue; + MaxSize = query.MaxSize ?? long.MaxValue; + ModifiedAfterTicks = ToUtcTicks(query.ModifiedAfter); + ModifiedBeforeTicks = ToUtcTicks(query.ModifiedBefore); + // 说明:SearchQuery.CreatedAfter 无法参与过滤 —— IndexedEntry 没有创建时间字段 + //($STANDARD_INFORMATION 的创建时间只在原始 MFT 解析里拿得到),需要接口扩展才能支持。 + } + } + + /// 卷句柄可用但 FSCTL 被内核拒绝(权限/非 NTFS),携带原始 Win32 错误码供上层分类。 + private sealed class VolumeAccessException(int error, string message) : Exception(message) + { + internal int Error { get; } = error; + } + + /// 容量受限的最大堆:始终只保留“最好的” K 条。键越小越好,堆顶是当前最差的一条。 + private sealed class TopK(int capacity) + { + private readonly long[] _keys = new long[Math.Max(1, capacity)]; + private readonly int[] _indexes = new int[Math.Max(1, capacity)]; + private int _count; + + internal void Add(long key, int index) + { + if (_count < _keys.Length) + { + int i = _count++; + _keys[i] = key; + _indexes[i] = index; + SiftUp(i); + } + else if (key < _keys[0]) + { + _keys[0] = key; + _indexes[0] = index; + SiftDown(0); + } + } + + internal void DrainInto(TopK other) + { + for (int i = 0; i < _count; i++) other.Add(_keys[i], _indexes[i]); + } + + internal (long Key, int Index)[] ToSortedArray() + { + var result = new (long, int)[_count]; + for (int i = 0; i < _count; i++) result[i] = (_keys[i], _indexes[i]); + Array.Sort(result, static (a, b) => a.Item1.CompareTo(b.Item1)); + return result; + } + + private void SiftUp(int i) + { + while (i > 0) + { + int parent = (i - 1) >> 1; + if (_keys[parent] >= _keys[i]) break; + Swap(parent, i); + i = parent; + } + } + + private void SiftDown(int i) + { + while (true) + { + int left = (i << 1) + 1; + if (left >= _count) return; + int largest = left; + int right = left + 1; + if (right < _count && _keys[right] > _keys[left]) largest = right; + if (_keys[i] >= _keys[largest]) return; + Swap(i, largest); + i = largest; + } + } + + private void Swap(int a, int b) + { + (_keys[a], _keys[b]) = (_keys[b], _keys[a]); + (_indexes[a], _indexes[b]) = (_indexes[b], _indexes[a]); + } + } +} diff --git a/Services/Search/Usn/WildcardMatcher.cs b/Services/Search/Usn/WildcardMatcher.cs new file mode 100644 index 0000000..280f7ec --- /dev/null +++ b/Services/Search/Usn/WildcardMatcher.cs @@ -0,0 +1,107 @@ +namespace FluidExplorer.Services.Search.Usn; + +/// +/// 通配符匹配器:? 匹配任意单字符,* 匹配任意长度(含空), +/// 大小写不敏感(用固定区域的大小写折叠,不产生任何分配、不受当前区域影响)。 +/// +/// 实现为经典的“双指针 + 最近星号回溯”,最坏 O(n*m),但对文件名这种短串是纳秒级。 +/// +public static class WildcardMatcher +{ + public static bool IsMatch(ReadOnlySpan text, ReadOnlySpan pattern) + { + // ---- 快路径:绝大多数真实查询是 "*.json" / "log*" / "*tmp*" 这类“只有一个/两个通配符”的模式, + // 直接退化成 EndsWith/StartsWith/IndexOf(走的是 BCL 的高度优化实现),比通用回溯快一个数量级。 + int stars = 0, questions = 0, firstStar = -1, lastStar = -1; + for (int i = 0; i < pattern.Length; i++) + { + var c = pattern[i]; + if (c == '*') + { + stars++; + if (firstStar < 0) firstStar = i; + lastStar = i; + } + else if (c == '?') + { + questions++; + } + } + + if (questions == 0) + { + switch (stars) + { + case 0: + return text.Equals(pattern, StringComparison.OrdinalIgnoreCase); + case 1 when firstStar == 0: + return text.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase); + case 1 when firstStar == pattern.Length - 1: + return text.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase); + case 2 when firstStar == 0 && lastStar == pattern.Length - 1: + return text.IndexOf(pattern[1..^1], StringComparison.OrdinalIgnoreCase) >= 0; + } + } + + // ---- 通用路径:双指针 + 最近星号回溯 ---- + int t = 0, p = 0; + int starPattern = -1; + int starText = 0; + + while (t < text.Length) + { + if (p < pattern.Length && (pattern[p] == '?' || FoldEquals(pattern[p], text[t]))) + { + t++; + p++; + } + else if (p < pattern.Length && pattern[p] == '*') + { + // 记下最近的星号位置,先当它匹配空串继续往前走 + starPattern = p++; + starText = t; + } + else if (starPattern >= 0) + { + // 回溯:让最近的星号多吃一个字符 + p = starPattern + 1; + t = ++starText; + } + else + { + return false; + } + } + + while (p < pattern.Length && pattern[p] == '*') p++; + return p == pattern.Length; + } + + /// 是否包含通配符。 + public static bool HasWildcard(ReadOnlySpan pattern) => + pattern.IndexOfAny('*', '?') >= 0; + + /// + /// 返回模式开头连续的字面量前缀(遇到 * 或 ? 为止),用于给通配符命中排优先级。 + /// + public static ReadOnlySpan LiteralPrefix(ReadOnlySpan pattern) + { + int i = 0; + while (i < pattern.Length && pattern[i] is not ('*' or '?')) i++; + return pattern[..i]; + } + + /// 区域无关的大小写折叠比较(只处理 ASCII 与 BMP 常见情形,足够文件名使用)。 + internal static bool FoldEquals(char a, char b) => + a == b || char.ToUpperInvariant(a) == char.ToUpperInvariant(b); + + /// 模式里是否只剩星号(即 "*" 或 "**" 这类恒真模式)。 + internal static bool IsMatchAll(ReadOnlySpan pattern) + { + foreach (var c in pattern) + { + if (c != '*') return false; + } + return true; + } +} diff --git a/Services/Shell/KnownFolders.cs b/Services/Shell/KnownFolders.cs new file mode 100644 index 0000000..cbd1ce0 --- /dev/null +++ b/Services/Shell/KnownFolders.cs @@ -0,0 +1,116 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace FluidExplorer.Services.Shell; + +/// +/// 用外壳自己的 API(SHGetKnownFolderPath)解析系统文件夹, +/// 保证和资源管理器指向同一批真实位置(含 OneDrive 重定向后的"桌面/文档"等)。 +/// +public static class KnownFolders +{ + public static string Profile { get; } = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + public static string Desktop { get; } = Get(FolderId.Desktop); + public static string Documents { get; } = Get(FolderId.Documents); + public static string Downloads { get; } = Get(FolderId.Downloads); + public static string Pictures { get; } = Get(FolderId.Pictures); + public static string Music { get; } = Get(FolderId.Music); + public static string Videos { get; } = Get(FolderId.Videos); + public static string RecycleBin { get; } = @"shell:RecycleBinFolder"; + + /// 此电脑 / 回收站等虚拟外壳对象的解析名,交给外壳取图标与打开。 + public const string ThisPcParsingName = "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}"; + public const string RecycleBinParsingName = "::{645FF040-5081-101B-9F08-00AA002F954E}"; + public const string NetworkParsingName = "::{F02C1A0D-BE21-4350-88B0-7367FC96EF3C}"; + public const string HomeParsingName = "::{F874310E-B6B7-47DC-BC84-B9E6B38F5903}"; // 主页 + public const string GalleryParsingName = "::{E88865EA-0E1C-4E20-9AA6-EDCD0212C87C}"; // 图库 + + private static string Get(Guid id) + { + try + { + var hr = SHGetKnownFolderPath(ref id, 0, IntPtr.Zero, out var ptr); + if (hr != 0 || ptr == IntPtr.Zero) return string.Empty; + try { return Marshal.PtrToStringUni(ptr) ?? string.Empty; } + finally { Marshal.FreeCoTaskMem(ptr); } + } + catch + { + return string.Empty; + } + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern int SHGetKnownFolderPath(ref Guid rfid, uint dwFlags, IntPtr hToken, out IntPtr ppszPath); + + private static class FolderId + { + public static Guid Desktop = new("B4BFCC3A-DB2C-424C-B029-7FE99A87C641"); + public static Guid Documents = new("FDD39AD0-238F-46AF-ADB4-6C85480369C7"); + public static Guid Downloads = new("374DE290-123F-4565-9164-39C4925E467B"); + public static Guid Pictures = new("33E28130-4E1E-4676-835A-98395C3BC3BB"); + public static Guid Music = new("4BD8D571-6D19-48D3-BE97-422220080E43"); + public static Guid Videos = new("18989B1D-99B5-455B-841C-AB7C74E4DDFC"); + } +} + +/// +/// 卷信息(用于侧边栏"此电脑"和状态栏):显示名、总容量、可用空间、就绪状态。 +/// +public sealed class DriveItem +{ + public required string RootPath { get; init; } + public required string DisplayName { get; init; } + public required string VolumeLabel { get; init; } + public required string FileSystem { get; init; } + public long TotalBytes { get; init; } + public long FreeBytes { get; init; } + public int DriveType { get; init; } + public bool IsReady { get; init; } + + public double UsedRatio => TotalBytes <= 0 ? 0 : 1.0 - (double)FreeBytes / TotalBytes; + + public string CapacityText => !IsReady || TotalBytes <= 0 + ? "不可用" + : $"{FluidExplorer.Models.FileEntry.FormatSize(FreeBytes)} 可用,共 {FluidExplorer.Models.FileEntry.FormatSize(TotalBytes)}"; + + public string Glyph => DriveType switch + { + 2 => "\uE88E", // 可移动磁盘 + 3 => "\uEDA2", // 本地磁盘 + 4 => "\uE8CE", // 网络驱动器 + 5 => "\uE958", // 光驱 + _ => "\uEDA2" + }; + + public static IReadOnlyList Enumerate() + { + var list = new List(); + foreach (var drive in DriveInfo.GetDrives()) + { + try + { + var label = drive.IsReady ? drive.VolumeLabel : string.Empty; + var display = string.IsNullOrWhiteSpace(label) + ? (drive.Name.TrimEnd('\\') is { Length: > 0 } letter ? $"本地磁盘 ({letter})" : drive.Name) + : $"{label} ({drive.Name.TrimEnd('\\')})"; + list.Add(new DriveItem + { + RootPath = drive.Name, + DisplayName = display, + VolumeLabel = label, + FileSystem = drive.IsReady ? drive.DriveFormat : string.Empty, + TotalBytes = drive.IsReady ? drive.TotalSize : 0, + FreeBytes = drive.IsReady ? drive.TotalFreeSpace : 0, + DriveType = (int)drive.DriveType, + IsReady = drive.IsReady + }); + } + catch + { + // 未就绪的驱动器(空读卡器等)直接跳过 + } + } + return list; + } +} diff --git a/Services/Shell/NaturalStringComparer.cs b/Services/Shell/NaturalStringComparer.cs new file mode 100644 index 0000000..8bf1ae3 --- /dev/null +++ b/Services/Shell/NaturalStringComparer.cs @@ -0,0 +1,30 @@ +using System.Runtime.InteropServices; + +namespace FluidExplorer.Services.Shell; + +/// +/// 资源管理器"名称"列用的排序:调用系统 shlwapi 的 StrCmpLogicalW(自然排序,数字按数值比较)。 +/// 这样 "文件2" 会排在 "文件10" 前面,和原版体验一致。 +/// +public sealed class NaturalStringComparer : IComparer +{ + public static NaturalStringComparer Instance { get; } = new(); + + public int Compare(string? x, string? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return -1; + if (y is null) return 1; + try + { + return StrCmpLogicalW(x, y); + } + catch + { + return string.Compare(x, y, StringComparison.CurrentCultureIgnoreCase); + } + } + + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern int StrCmpLogicalW(string psz1, string psz2); +} diff --git a/Services/Shell/RecycleBinView.cs b/Services/Shell/RecycleBinView.cs new file mode 100644 index 0000000..59e5e51 --- /dev/null +++ b/Services/Shell/RecycleBinView.cs @@ -0,0 +1,109 @@ +using System.Buffers.Binary; +using System.Text; + +namespace FluidExplorer.Services.Shell; + +/// 回收站里的一条记录(从 $I 元数据文件解析,含原始路径)。 +public sealed record RecycleBinEntry( + string RecyclePath, + string OriginalPath, + long Size, + DateTime DeletedUtc, + string VolumeRoot) +{ + public string Name => Services.FileSystem.PathHelper.GetName(OriginalPath); + public string OriginalDirectory => Services.FileSystem.PathHelper.GetParent(OriginalPath); + public bool IsDirectory => Size == 0 && OriginalPath.Length > 0 && !Path.HasExtension(OriginalPath); +} + +/// +/// 直接读取各卷的 $Recycle.Bin\<SID>\$I* 元数据文件,得到回收站内容与原始路径。 +/// 这样"还原/彻底删除"都能由本程序完成(不依赖系统弹窗),也支持一步撤销。 +/// +public static class RecycleBinView +{ + public static async Task> EnumerateAsync(CancellationToken cancellationToken) + => await Task.Run(() => Enumerate(cancellationToken), cancellationToken).ConfigureAwait(false); + + public static IReadOnlyList Enumerate(CancellationToken cancellationToken = default) + { + var results = new List(); + foreach (var drive in DriveInfo.GetDrives()) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + if (!drive.IsReady || drive.DriveType != DriveType.Fixed) continue; + var binRoot = Path.Combine(drive.Name, "$Recycle.Bin"); + if (!Directory.Exists(binRoot)) continue; + + foreach (var sidDir in Directory.EnumerateDirectories(binRoot)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + foreach (var metaFile in Directory.EnumerateFiles(sidDir, "$I*")) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = TryParse(metaFile, drive.Name); + if (entry is not null) results.Add(entry); + } + } + catch + { + // 其他用户的回收站目录通常无权限,跳过 + } + } + } + catch + { + // 卷不可读时跳过 + } + } + + return results; + } + + /// 解析单个 $I 元数据文件。 + public static RecycleBinEntry? TryParse(string metaFilePath, string volumeRoot) + { + try + { + var bytes = File.ReadAllBytes(metaFilePath); + if (bytes.Length < 24) return null; + + var version = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(0, 8)); + var size = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(8, 8)); + var fileTime = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(16, 8)); + var deleted = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue; + + string originalPath; + if (version >= 2 && bytes.Length >= 28) + { + var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(24, 4)); + if (length <= 0 || 28 + length * 2 > bytes.Length) return null; + originalPath = Encoding.Unicode.GetString(bytes, 28, length * 2).TrimEnd('\0'); + } + else if (version == 1) + { + // 旧格式:路径固定在 0x2C 偏移处的 260 个宽字符 + if (bytes.Length < 0x2C + 520) return null; + originalPath = Encoding.Unicode.GetString(bytes, 0x2C, 520).TrimEnd('\0'); + } + else + { + return null; + } + + if (string.IsNullOrWhiteSpace(originalPath)) return null; + + var fileName = Path.GetFileName(metaFilePath); + var recyclePath = Path.Combine(Path.GetDirectoryName(metaFilePath)!, "$R" + fileName[2..]); + return new RecycleBinEntry(recyclePath, originalPath, Math.Max(0, size), deleted, volumeRoot); + } + catch + { + return null; + } + } +} diff --git a/Services/Shell/ShellActions.cs b/Services/Shell/ShellActions.cs new file mode 100644 index 0000000..b176d82 --- /dev/null +++ b/Services/Shell/ShellActions.cs @@ -0,0 +1,223 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace FluidExplorer.Services.Shell; + +/// +/// 交给 Windows 外壳去做的动作(打开、属性、打开方式、在资源管理器中显示…)。 +/// 全部走系统原版行为,保证和资源管理器完全一致。 +/// +public static class ShellActions +{ + /// 用默认程序/默认动作打开(= 双击)。 + public static bool Open(string path) + { + try + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + return true; + } + catch + { + return false; + } + } + + /// 打开"打开方式"选择器。 + public static bool OpenWith(string path) + { + try + { + Process.Start(new ProcessStartInfo("rundll32.exe", $"shell32.dll,OpenAs_RunDLL {path}") { UseShellExecute = true }); + return true; + } + catch + { + return false; + } + } + + /// 调用系统原版"属性"对话框(含只读/隐藏复选框、磁盘清理等)。 + public static bool ShowProperties(IntPtr ownerHwnd, IReadOnlyList paths) + { + if (paths.Count == 0) return false; + var info = new SHELLEXECUTEINFO + { + cbSize = Marshal.SizeOf(), + fMask = SEE_MASK_INVOKEIDLIST | SEE_MASK_FLAG_NO_UI, + hwnd = ownerHwnd, + lpVerb = "properties", + lpFile = paths[0], + nShow = 5 + }; + + if (paths.Count == 1) return ShellExecuteEx(ref info); + + // 多选时用外壳的多文件属性对话框 + try + { + var files = string.Join('\0', paths) + "\0\0"; + var ptr = Marshal.StringToHGlobalUni(files); + try + { + info.lpFile = null; + var psi = new SHFILEINFO(); + var hwnd = SHMultiFileProperties(new DataObjectNative { pFiles = ptr }, 0); + _ = hwnd; + _ = psi; + // SHMultiFileProperties 需要 IDataObject 实现,较繁琐;退化为逐项打开第一个的属性 + info.lpFile = paths[0]; + return ShellExecuteEx(ref info); + } + finally + { + Marshal.FreeHGlobal(ptr); + } + } + catch + { + return false; + } + } + + /// 在系统资源管理器中定位并选中该文件(用于"在资源管理器中显示")。 + public static bool RevealInExplorer(string path) + { + try + { + Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true }); + return true; + } + catch + { + return false; + } + } + + /// 运行对话框式的"运行"入口(用于 shell: 位置)。 + public static bool OpenShellLocation(string parsingName) + { + try + { + Process.Start(new ProcessStartInfo("explorer.exe", parsingName) { UseShellExecute = true }); + return true; + } + catch + { + return false; + } + } + + /// 把"打开"当作动词执行(用于右键菜单的"打开")。 + public static bool Execute(string path, string verb) + { + var info = new SHELLEXECUTEINFO + { + cbSize = Marshal.SizeOf(), + fMask = SEE_MASK_INVOKEIDLIST | SEE_MASK_FLAG_NO_UI, + lpVerb = verb, + lpFile = path, + nShow = 1 + }; + return ShellExecuteEx(ref info); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct SHELLEXECUTEINFO + { + public int cbSize; + public uint fMask; + public IntPtr hwnd; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpVerb; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpFile; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpParameters; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpDirectory; + public int nShow; + public IntPtr hInstApp; + public IntPtr lpIDList; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpClass; + public IntPtr hkeyClass; + public uint dwHotKey; + public IntPtr hIcon; + public IntPtr hProcess; + } + + [StructLayout(LayoutKind.Sequential)] + private struct SHFILEINFO + { + public IntPtr hIcon; + public int iIcon; + public uint dwAttributes; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct DataObjectNative + { + public IntPtr pFiles; + } + + private const uint SEE_MASK_INVOKEIDLIST = 0x0000000C; + private const uint SEE_MASK_FLAG_NO_UI = 0x00000400; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool ShellExecuteEx(ref SHELLEXECUTEINFO lpExecInfo); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SHMultiFileProperties(DataObjectNative pdtobj, uint dwFlags); + + /// 把文件放入剪贴板(CF_HDROP,与其他程序互通)。 + public static bool SetClipboardFiles(IReadOnlyList paths, bool cut) + { + try + { + var sb = new StringBuilder(); + foreach (var p in paths) sb.Append(p).Append('\0'); + sb.Append('\0'); + var bytes = Encoding.Unicode.GetBytes(sb.ToString()); + var hGlobal = Marshal.AllocHGlobal(bytes.Length + 20); + if (hGlobal == IntPtr.Zero) return false; + + // DROPFILES 结构 + 文件名列表 + var dropFiles = new byte[20 + bytes.Length]; + BitConverter.GetBytes(20).CopyTo(dropFiles, 0); // pFiles 偏移 + BitConverter.GetBytes(0).CopyTo(dropFiles, 4); // pt.x + BitConverter.GetBytes(0).CopyTo(dropFiles, 8); // pt.y + BitConverter.GetBytes(0).CopyTo(dropFiles, 12); // fNC + BitConverter.GetBytes(1).CopyTo(dropFiles, 16); // fWide = TRUE + bytes.CopyTo(dropFiles, 20); + Marshal.Copy(dropFiles, 0, hGlobal, dropFiles.Length); + + var format = RegisterClipboardFormat(cut ? "Preferred DropEffect" : "Preferred DropEffect"); + var effect = new byte[4]; + BitConverter.GetBytes(cut ? 2 : 5).CopyTo(effect, 0); // DROPEFFECT_MOVE=2 / COPY=5 + var hEffect = Marshal.AllocHGlobal(4); + Marshal.Copy(effect, 0, hEffect, 4); + + if (!OpenClipboard(IntPtr.Zero)) { Marshal.FreeHGlobal(hGlobal); Marshal.FreeHGlobal(hEffect); return false; } + try + { + EmptyClipboard(); + SetClipboardData(15 /*CF_HDROP*/, hGlobal); + SetClipboardData(format, hEffect); + } + finally + { + CloseClipboard(); + } + return true; + } + catch + { + return false; + } + } + + [DllImport("user32.dll", SetLastError = true)] private static extern bool OpenClipboard(IntPtr hWndNewOwner); + [DllImport("user32.dll", SetLastError = true)] private static extern bool CloseClipboard(); + [DllImport("user32.dll", SetLastError = true)] private static extern bool EmptyClipboard(); + [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem); + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern uint RegisterClipboardFormat(string lpszFormat); +} diff --git a/Services/Shell/ShellContextMenu.cs b/Services/Shell/ShellContextMenu.cs new file mode 100644 index 0000000..202cf66 --- /dev/null +++ b/Services/Shell/ShellContextMenu.cs @@ -0,0 +1,267 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; + +namespace FluidExplorer.Services.Shell; + +/// +/// Windows 系统原版右键菜单(shell 的 IContextMenu): +/// 就是资源管理器"显示更多选项"里那一套(含第三方扩展、发送到、打开方式、Windows Terminal 等), +/// 直接用系统实现,不自己造菜单项。 +/// +public static class ShellContextMenu +{ + public static Task ShowAsync(IntPtr ownerHwnd, IReadOnlyList paths, Windows.Graphics.PointInt32 screenPoint) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + + void Run() + { + try + { + ShowCore(ownerHwnd, paths, screenPoint.X, screenPoint.Y); + tcs.TrySetResult(); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + } + + if (dispatcher is null || dispatcher.HasThreadAccess) Run(); + else dispatcher.TryEnqueue(Run); + return tcs.Task; + } + + private static void ShowCore(IntPtr hwnd, IReadOnlyList paths, int x, int y) + { + if (paths.Count == 0) return; + + var pidls = new List(); + var allocated = new List(); + IShellFolder? parentFolder = null; + + try + { + // 取第一条的父文件夹,作为构建 IContextMenu 的宿主(多选时要求同一父目录) + var firstHr = SHParseDisplayName(paths[0], IntPtr.Zero, out var firstPidl, 0, out _); + if (firstHr != 0 || firstPidl == IntPtr.Zero) return; + allocated.Add(firstPidl); + + var bindHr = SHBindToParent(firstPidl, typeof(IShellFolder).GUID, out var folderObj, out var childPidl); + if (bindHr != 0 || folderObj is null) return; + parentFolder = (IShellFolder)folderObj; + + var childPidls = new List { childPidl }; + for (var i = 1; i < paths.Count; i++) + { + if (SHParseDisplayName(paths[i], IntPtr.Zero, out var pidl, 0, out _) != 0 || pidl == IntPtr.Zero) continue; + allocated.Add(pidl); + var hr = SHBindToParent(pidl, typeof(IShellFolder).GUID, out var folder, out var child); + if (hr != 0 || folder is null) continue; + if (folder != parentFolder) continue; // 不同目录的多选:忽略额外项(由上层逐个处理) + childPidls.Add(child); + } + + var iid = typeof(IContextMenu).GUID; + var uiObjectHr = parentFolder.GetUIObjectOf(hwnd, (uint)childPidls.Count, childPidls.ToArray(), ref iid, IntPtr.Zero, out var contextMenuObj); + if (uiObjectHr != 0 || contextMenuObj is null) return; + + var contextMenu = (IContextMenu)contextMenuObj; + var contextMenu2 = contextMenuObj as IContextMenu2; + var contextMenu3 = contextMenuObj as IContextMenu3; + + var hMenu = CreatePopupMenu(); + if (hMenu == IntPtr.Zero) return; + + try + { + const uint CMF_NORMAL = 0x00000000; + const uint CMF_EXTENDEDVERBS = 0x00000100; + var queryHr = contextMenu.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_NORMAL | CMF_EXTENDEDVERBS); + if (queryHr < 0) return; + + // 系统菜单里"打开方式/发送到"等子菜单需要转发 owner-draw 消息 + using var hook = contextMenu2 is null && contextMenu3 is null + ? null + : new MenuMessageHook(hwnd, contextMenu2, contextMenu3); + + const uint TPM_RETURNCMD = 0x0100; + const uint TPM_RIGHTBUTTON = 0x0002; + var command = TrackPopupMenuEx(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON, x, y, hwnd, IntPtr.Zero); + if (command <= 0) return; + + var info = new CMINVOKECOMMANDINFOEX + { + cbSize = Marshal.SizeOf(), + fMask = 0x00004000 /*CMIC_MASK_UNICODE*/, + hwnd = hwnd, + lpVerb = (IntPtr)(command - 1), + lpVerbW = (IntPtr)(command - 1), + nShow = 1 + }; + + var invokeHr = contextMenu.InvokeCommand(ref info); + _ = invokeHr; + } + finally + { + DestroyMenu(hMenu); + } + } + finally + { + foreach (var pidl in allocated) Marshal.FreeCoTaskMem(pidl); + if (parentFolder is not null) Marshal.ReleaseComObject(parentFolder); + } + } + + /// 把菜单的 owner-draw / init 消息转发给 IContextMenu2/3(子菜单才能正常展开)。 + private sealed class MenuMessageHook : IDisposable + { + private readonly IntPtr _hwnd; + private readonly IContextMenu2? _menu2; + private readonly IContextMenu3? _menu3; + private readonly SubclassProc _proc; + private readonly IntPtr _oldProc; + + public MenuMessageHook(IntPtr hwnd, IContextMenu2? menu2, IContextMenu3? menu3) + { + _hwnd = hwnd; + _menu2 = menu2; + _menu3 = menu3; + _proc = HookProc; + _oldProc = SetWindowLongPtr(hwnd, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_proc)); + } + + private IntPtr HookProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam) + { + switch (msg) + { + case WM_INITMENUPOPUP: + case WM_DRAWITEM: + case WM_MEASUREITEM: + case WM_MENUCHAR: + if (_menu3 is not null) + { + var handled = IntPtr.Zero; + if (_menu3.HandleMenuMsg2(msg, wParam, lParam, out handled) == 0 && handled != IntPtr.Zero) return handled; + } + else if (_menu2 is not null) + { + if (_menu2.HandleMenuMsg(msg, wParam, lParam) == 0) return IntPtr.Zero; + } + break; + } + return CallWindowProc(_oldProc, hwnd, msg, wParam, lParam); + } + + public void Dispose() + { + if (_oldProc != IntPtr.Zero) SetWindowLongPtr(_hwnd, GWLP_WNDPROC, _oldProc); + } + } + + private const int GWLP_WNDPROC = -4; + private const uint WM_INITMENUPOPUP = 0x0117; + private const uint WM_DRAWITEM = 0x002B; + private const uint WM_MEASUREITEM = 0x002C; + private const uint WM_MENUCHAR = 0x0120; + + private delegate IntPtr SubclassProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CMINVOKECOMMANDINFOEX + { + public int cbSize; + public uint fMask; + public IntPtr hwnd; + public IntPtr lpVerb; + public IntPtr lpParameters; + public IntPtr lpDirectory; + public int nShow; + public uint dwHotKey; + public IntPtr hIcon; + public IntPtr lpTitle; + public IntPtr lpVerbW; + public IntPtr lpParametersW; + public IntPtr lpDirectoryW; + public IntPtr lpTitleW; + public POINT ptInvoke; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + [ComImport] + [Guid("000214E6-0000-0000-C000-000000000046")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellFolder + { + [PreserveSig] int ParseDisplayName(IntPtr hwnd, IntPtr pbc, [MarshalAs(UnmanagedType.LPWStr)] string pszDisplayName, out uint pchEaten, out IntPtr ppidl, ref uint pdwAttributes); + [PreserveSig] int EnumObjects(IntPtr hwnd, uint grfFlags, out IntPtr ppenumIDList); + [PreserveSig] int BindToObject(IntPtr pidl, IntPtr pbc, ref Guid riid, out IntPtr ppv); + [PreserveSig] int BindToStorage(IntPtr pidl, IntPtr pbc, ref Guid riid, out IntPtr ppv); + [PreserveSig] int CompareIDs(IntPtr lParam, IntPtr pidl1, IntPtr pidl2); + [PreserveSig] int CreateViewObject(IntPtr hwndOwner, ref Guid riid, out IntPtr ppv); + [PreserveSig] int GetAttributesOf(uint cidl, IntPtr[] apidl, ref uint rgfInOut); + [PreserveSig] int GetUIObjectOf(IntPtr hwndOwner, uint cidl, IntPtr[] apidl, ref Guid riid, IntPtr rgfReserved, out object ppv); + [PreserveSig] int GetDisplayNameOf(IntPtr pidl, uint uFlags, out IntPtr pName); + [PreserveSig] int SetNameOf(IntPtr hwnd, IntPtr pidl, [MarshalAs(UnmanagedType.LPWStr)] string pszName, uint uFlags, out IntPtr ppidlOut); + } + + [ComImport] + [Guid("000214E4-0000-0000-C000-000000000046")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IContextMenu + { + [PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags); + [PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici); + [PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax); + } + + [ComImport] + [Guid("000214F4-0000-0000-C000-000000000046")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IContextMenu2 + { + [PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags); + [PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici); + [PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax); + [PreserveSig] int HandleMenuMsg(uint uMsg, IntPtr wParam, IntPtr lParam); + } + + [ComImport] + [Guid("BCFCE0A0-EC17-11D0-8D10-00A0C90F2719")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IContextMenu3 + { + [PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags); + [PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici); + [PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax); + [PreserveSig] int HandleMenuMsg(uint uMsg, IntPtr wParam, IntPtr lParam); + [PreserveSig] int HandleMenuMsg2(uint uMsg, IntPtr wParam, IntPtr lParam, out IntPtr plResult); + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHParseDisplayName(string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHBindToParent(IntPtr pidl, Guid riid, out object ppv, out IntPtr ppidlLast); + + [DllImport("user32.dll")] private static extern IntPtr CreatePopupMenu(); + [DllImport("user32.dll")] private static extern bool DestroyMenu(IntPtr hMenu); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int TrackPopupMenuEx(IntPtr hMenu, uint fuFlags, int x, int y, IntPtr hwnd, IntPtr lptpm); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); +} diff --git a/Services/Shell/TypeNameResolver.cs b/Services/Shell/TypeNameResolver.cs new file mode 100644 index 0000000..2d9ef3e --- /dev/null +++ b/Services/Shell/TypeNameResolver.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; +using Microsoft.Win32; + +namespace FluidExplorer.Services.Shell; + +/// +/// 用注册表把扩展名映射成资源管理器里显示的友好类型名(例如 "PDF 文档"、"文本文档")。 +/// 结果缓存;失败时回退到 "XXX 文件"。 +/// +public static class TypeNameResolver +{ + private static readonly ConcurrentDictionary Cache = new(StringComparer.OrdinalIgnoreCase); + + public static string GetTypeName(string extension, bool isDirectory) + { + if (isDirectory) return "文件夹"; + if (string.IsNullOrEmpty(extension)) return "文件"; + return Cache.GetOrAdd(extension, static ext => + { + try + { + var key = "." + ext; + using var extKey = Registry.ClassesRoot.OpenSubKey(key); + if (extKey is null) return $"{ext.ToUpperInvariant()} 文件"; + + var progId = extKey.GetValue(null) as string; + if (!string.IsNullOrEmpty(progId) && (progId.StartsWith("AppX", StringComparison.OrdinalIgnoreCase) + || progId.Contains("_", StringComparison.Ordinal))) + { + // AppX/UWP 关联:优先用 "FriendlyTypeName"(本地化资源,直接读字符串) + var friendly = extKey.GetValue("FriendlyTypeName") as string; + if (!string.IsNullOrWhiteSpace(friendly)) return friendly; + } + + if (!string.IsNullOrEmpty(progId)) + { + using var progKey = Registry.ClassesRoot.OpenSubKey(progId); + if (progKey is not null) + { + var name = progKey.GetValue("FriendlyTypeName") as string ?? progKey.GetValue(null) as string; + if (!string.IsNullOrWhiteSpace(name)) + { + // 间接字符串(@dll,-id)无法直接解析,交回扩展名兜底 + return name.StartsWith('@') ? $"{ext.ToUpperInvariant()} 文件" : name; + } + } + } + + var extFriendly = extKey.GetValue("FriendlyTypeName") as string; + if (!string.IsNullOrWhiteSpace(extFriendly) && !extFriendly.StartsWith('@')) return extFriendly; + } + catch + { + // 注册表不可读(权限/损坏)时静默回退 + } + + return $"{ext.ToUpperInvariant()} 文件"; + }); + } +} diff --git a/Themes/Glyphs.xaml b/Themes/Glyphs.xaml new file mode 100644 index 0000000..d12536a --- /dev/null +++ b/Themes/Glyphs.xaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Themes/Styles.xaml b/Themes/Styles.xaml new file mode 100644 index 0000000..892e0d8 --- /dev/null +++ b/Themes/Styles.xaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + 48 + 40 + 30 + 26 + + + + + + + + + + + + + + + + + diff --git a/ViewModels/ExplorerPaneViewModel.cs b/ViewModels/ExplorerPaneViewModel.cs new file mode 100644 index 0000000..d7776c8 --- /dev/null +++ b/ViewModels/ExplorerPaneViewModel.cs @@ -0,0 +1,1134 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FluidExplorer.Models; +using FluidExplorer.Navigation; +using FluidExplorer.Services; +using FluidExplorer.Services.FileSystem; +using FluidExplorer.Services.ItemVisuals; +using FluidExplorer.Services.Operations; +using FluidExplorer.Services.Search; +using FluidExplorer.Services.Shell; +using FluidExplorer.Helpers; +using Microsoft.UI.Dispatching; +using PathHelper = FluidExplorer.Services.FileSystem.PathHelper; + +namespace FluidExplorer.ViewModels; + +/// 复制/剪切剪贴板(应用内共享,跨窗格粘贴是"搬文件"最快的方式)。 +public interface IFileClipboard +{ + IReadOnlyList Paths { get; } + bool IsCut { get; } + void Set(IReadOnlyList paths, bool cut); + void Clear(); + event EventHandler? Changed; +} + +public sealed class FileClipboard : IFileClipboard +{ + private List _paths = []; + + public IReadOnlyList Paths => _paths; + public bool IsCut { get; private set; } + + public event EventHandler? Changed; + + public void Set(IReadOnlyList paths, bool cut) + { + _paths = [.. paths]; + IsCut = cut; + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Clear() + { + _paths = []; + IsCut = false; + Changed?.Invoke(this, EventArgs.Empty); + } +} + +/// +/// 一个浏览窗格的全部状态与行为(单窗格 = 一个标签页;双窗格 = 同一标签页里的两个实例)。 +/// 所有 IO 都是异步 + 可取消的,界面线程永远不会被文件系统拖住。 +/// +public sealed partial class ExplorerPaneViewModel : ObservableObject +{ + private readonly IFileSystemService _fs; + private readonly SearchService _search; + private readonly IFileOperationService _operations; + private readonly ItemVisualService _visuals; + private readonly AppSettings _settings; + private readonly IFileClipboard _clipboard; + private readonly DispatcherQueue _ui; + + private CancellationTokenSource? _navigationCts; + private CancellationTokenSource? _searchCts; + private readonly List _allItems = []; + private readonly Dictionary _recycleOriginalPaths = new(StringComparer.OrdinalIgnoreCase); + private int _progressiveCount; + + public ExplorerPaneViewModel( + IFileSystemService fs, + SearchService search, + IFileOperationService operations, + ItemVisualService visuals, + IFileClipboard clipboard, + AppSettings settings, + DispatcherQueue ui) + { + _fs = fs; + _search = search; + _operations = operations; + _visuals = visuals; + _clipboard = clipboard; + _settings = settings; + _ui = ui; + _clipboard.Changed += (_, _) => PasteCommand.NotifyCanExecuteChanged(); + _operations.UndoStackChanged += (_, _) => + { + UndoCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(UndoDescription)); + }; + _operations.JobsChanged += (_, _) => + { + UndoCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(UndoDescription)); + }; + _viewMode = _settings.GetFolderView(KnownFolders.HomeParsingName).ViewMode; + var state = _settings.GetFolderView(KnownFolders.HomeParsingName); + _sort = new SortSpec(state.SortColumn, state.SortDirection); + } + + // ── 位置状态 ──────────────────────────────────────────────────────────── + [ObservableProperty] private NavigationLocation _location = NavigationLocation.Home; + [ObservableProperty] private string _currentPath = string.Empty; + [ObservableProperty] private string _title = "主页"; + [ObservableProperty] private string _glyph = "\uE80F"; + [ObservableProperty] private string _addressText = string.Empty; + [ObservableProperty] private ObservableCollection _breadcrumbs = []; + + // ── 内容状态 ──────────────────────────────────────────────────────────── + [ObservableProperty] private ObservableCollection _items = []; + [ObservableProperty] private ObservableCollection _searchResults = []; + [ObservableProperty] private bool _isLoading; + [ObservableProperty] private bool _isSearchActive; + [ObservableProperty] private bool _isSearching; + [ObservableProperty] private string _searchText = string.Empty; + [ObservableProperty] private string _searchStatusText = string.Empty; + [ObservableProperty] private string _statusText = "就绪"; + [ObservableProperty] private string _selectionText = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? _errorMessage; + [ObservableProperty] private string? _emptyMessage; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNote))] + private string? _noteMessage; + [ObservableProperty] private bool _isRecycleBin; + [ObservableProperty] private bool _isGallery; + + /// 是否显示左侧导航窗格(双窗格时只有主窗格显示)。 + [ObservableProperty] private bool _showSidebar = true; + + public bool HasError => !string.IsNullOrEmpty(ErrorMessage); + public bool HasNote => !string.IsNullOrEmpty(NoteMessage); + + // ── 视图状态 ──────────────────────────────────────────────────────────── + [ObservableProperty] private ViewMode _viewMode = ViewMode.Details; + [ObservableProperty] private SortSpec _sort = SortSpec.Default; + [ObservableProperty] private GroupBy _groupBy = GroupBy.None; + [ObservableProperty] private bool _showHidden; + + public NavigationHistory History { get; } = new(); + public ItemVisualService Visuals => _visuals; + public bool ShowSystemFiles => _settings.ShowSystemFiles; + public string? UndoDescription => _operations.CanUndo ? _operations.UndoDescription : null; + + /// 网格视图的单元格尺寸(图标 + 一行留白,与资源管理器一致的紧凑排布)。 + public double TileWidth => IconSize + 36; + public double TileHeight => IconSize + 46; + /// 详情/列表视图的行高。 + public double RowHeight => ViewMode == ViewMode.List ? 24 : 30; + + public event EventHandler? ErrorOccurred; + public event EventHandler? RenameRequested; + public event EventHandler? ScrollIntoViewRequested; + public event EventHandler? OpenInNewTabRequested; + + /// 请求把焦点放进搜索框(Ctrl+F / Ctrl+E)。 + public event EventHandler? FocusSearchRequested; + + /// 供视图调用:把焦点放进搜索框。 + public void RequestSearchFocus() => FocusSearchRequested?.Invoke(this, EventArgs.Empty); + + /// 把错误显示在本窗格顶部的 InfoBar 里(不打断操作)。 + public void ReportError(string message) => ErrorMessage = message; + + public void DismissError() => ErrorMessage = null; + + /// 点击地址栏上的"复制地址"等动作时使用。 + public void FocusAddressBar() => AddressBarFocusRequested?.Invoke(this, EventArgs.Empty); + + public event EventHandler? AddressBarFocusRequested; + + // ── 导航 ──────────────────────────────────────────────────────────────── + public Task NavigateAsync(NavigationLocation location, bool addToHistory = true) + { + _navigationCts?.Cancel(); + var cts = new CancellationTokenSource(); + _navigationCts = cts; + return NavigateCoreAsync(location, addToHistory, cts.Token); + } + + private async Task NavigateCoreAsync(NavigationLocation location, bool addToHistory, CancellationToken ct) + { + Location = location; + Title = location.DisplayName; + Glyph = location.Glyph; + IsRecycleBin = location.Kind == LocationKind.RecycleBin; + IsGallery = location.Kind == LocationKind.Gallery; + ErrorMessage = null; + NoteMessage = null; + IsSearchActive = false; + SearchResults = []; + SearchText = string.Empty; + _recycleOriginalPaths.Clear(); + _visuals.ResetPending(); + + if (addToHistory) History.Push(location); + + BackCommand.NotifyCanExecuteChanged(); + ForwardCommand.NotifyCanExecuteChanged(); + UpCommand.NotifyCanExecuteChanged(); + + var viewState = _settings.GetFolderView(location.Path); + if (addToHistory) + { + ViewMode = viewState.ViewMode; + Sort = new SortSpec(viewState.SortColumn, viewState.SortDirection); + GroupBy = viewState.GroupBy; + } + + if (location.IsVirtual) + { + CurrentPath = location.Path; + AddressText = location.DisplayName == "此电脑" && location.Kind == LocationKind.ThisPc + ? "此电脑" + : location.DisplayName; + BuildBreadcrumbs(location); + await LoadVirtualAsync(location, ct).ConfigureAwait(true); + return; + } + + CurrentPath = location.Path; + AddressText = location.Path; + BuildBreadcrumbs(location); + await LoadFolderAsync(location.Path, ct).ConfigureAwait(true); + } + + private async Task LoadFolderAsync(string path, CancellationToken ct) + { + IsLoading = true; + _allItems.Clear(); + _progressiveCount = 0; + var display = new ObservableCollection(); + Items = display; + EmptyMessage = null; + + var listing = new FolderListing { Path = path }; + var sw = Stopwatch.StartNew(); + try + { + await _fs.EnumerateAsync(path, listing, async batch => + { + if (ct.IsCancellationRequested) return; + var mapped = new List(batch.Count); + foreach (var entry in batch) + { + if (entry.IsHidden && !_settings.ShowHiddenFiles) continue; + if (entry.IsSystem && !_settings.ShowSystemFiles) continue; + var item = new ExplorerItem(entry) { DisplayName = BuildDisplayName(entry) }; + mapped.Add(item); + } + if (mapped.Count == 0) return; + + await _ui.EnqueueAsync(() => + { + if (ct.IsCancellationRequested) return; + foreach (var item in mapped) + { + _allItems.Add(item); + display.Add(item); + } + _progressiveCount = display.Count; + IsLoading = false; + UpdateStatusText(); + }).ConfigureAwait(false); + }, 256, ct).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + + if (ct.IsCancellationRequested) return; + + EmptyMessage = _allItems.Count == 0 ? "此文件夹为空" : null; + ErrorMessage = listing.Error; + IsLoading = false; + ApplySortAndRefresh(); + UpdateStatusText(); + + // 为前若干行预取图标(后面的由界面按可见性按需请求) + PrimeIcons(120); + sw.Stop(); + App.Log($"[nav] {path}: {_allItems.Count} 项, {sw.ElapsedMilliseconds} ms"); + + // 记录"最近使用" + RecordRecent(path); + } + + private async Task LoadVirtualAsync(NavigationLocation location, CancellationToken ct) + { + IsLoading = true; + _allItems.Clear(); + Items = []; + EmptyMessage = null; + + switch (location.Kind) + { + case LocationKind.ThisPc: + await LoadDrivesAsync(ct).ConfigureAwait(true); + break; + + case LocationKind.RecycleBin: + await LoadRecycleBinAsync(ct).ConfigureAwait(true); + break; + + case LocationKind.Gallery: + await LoadGalleryAsync(ct).ConfigureAwait(true); + break; + + case LocationKind.Network: + foreach (var drive in DriveItem.Enumerate().Where(d => d.DriveType == 4)) + _allItems.Add(MakeFolderItem(drive.RootPath, drive.DisplayName)); + EmptyMessage = _allItems.Count == 0 ? "没有已映射的网络位置。可在资源管理器中映射网络驱动器后再回来查看。" : null; + break; + + case LocationKind.Home: + default: + LoadHome(); + break; + } + + IsLoading = false; + ApplySortAndRefresh(); + UpdateStatusText(); + PrimeIcons(80); + } + + private Task LoadDrivesAsync(CancellationToken ct) + { + foreach (var drive in DriveItem.Enumerate()) + { + ct.ThrowIfCancellationRequested(); + var item = new ExplorerItem(new FileEntry + { + Name = drive.DisplayName, + FullPath = drive.RootPath, + IsDirectory = true, + ModifiedUtc = DateTime.UtcNow, + Attributes = FileAttributes.Directory + }) + { DisplayName = drive.DisplayName }; + _allItems.Add(item); + } + EmptyMessage = _allItems.Count == 0 ? "没有可用的驱动器" : null; + return Task.CompletedTask; + } + + private async Task LoadRecycleBinAsync(CancellationToken ct) + { + var entries = await RecycleBinView.EnumerateAsync(ct).ConfigureAwait(true); + foreach (var entry in entries) + { + if (ct.IsCancellationRequested) return; + bool isDir; + try { isDir = Directory.Exists(entry.RecyclePath); } + catch { isDir = false; } + + _recycleOriginalPaths[entry.RecyclePath] = entry.OriginalPath; + var item = new ExplorerItem(new FileEntry + { + Name = entry.Name, + FullPath = entry.RecyclePath, + IsDirectory = isDir, + Size = isDir ? 0 : entry.Size, + ModifiedUtc = entry.DeletedUtc == DateTime.MinValue ? DateTime.UtcNow : entry.DeletedUtc, + CreatedUtc = entry.DeletedUtc, + Attributes = isDir ? FileAttributes.Directory : FileAttributes.Normal + }) + { DisplayName = entry.Name }; + _allItems.Add(item); + } + EmptyMessage = _allItems.Count == 0 ? "回收站是空的" : null; + RestoreSelectedCommand.NotifyCanExecuteChanged(); + } + + private async Task LoadGalleryAsync(CancellationToken ct) + { + var query = SearchQueryParser.Parse("*"); + query.Extensions.AddRange(["jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "tif", "tiff", "avif", "mp4", "mov", "mkv"]); + + var outcome = await _search.SearchAsync(query, SearchScope.Global, null, 3000, ct).ConfigureAwait(true); + foreach (var hit in outcome.Hits) + { + if (ct.IsCancellationRequested) return; + _allItems.Add(new ExplorerItem(new FileEntry + { + Name = hit.Name, + FullPath = hit.Path, + IsDirectory = false, + Size = hit.Size, + ModifiedUtc = hit.ModifiedUtc == DateTime.MinValue ? DateTime.UtcNow : hit.ModifiedUtc, + Attributes = FileAttributes.Normal + }) + { DisplayName = BuildDisplayName(new FileEntry { Name = hit.Name, FullPath = hit.Path }) }); + } + + Sort = new SortSpec(SortColumn.DateModified, SortDirection.Descending); + if (_allItems.Count == 0) + EmptyMessage = outcome.UsedIndex ? "没有找到图片或视频" : "图库需要 NTFS 索引才能即时显示(可在设置中开启索引)"; + } + + private void LoadHome() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in _settings.PinnedFolders.Concat(_settings.RecentFolders)) + { + if (string.IsNullOrWhiteSpace(path) || !seen.Add(path)) continue; + if (!SafeDirectoryExists(path)) continue; + _allItems.Add(MakeFolderItem(path, PathHelper.GetName(path))); + } + + if (_allItems.Count == 0) + { + foreach (var path in new[] { KnownFolders.Desktop, KnownFolders.Downloads, KnownFolders.Documents, KnownFolders.Pictures }) + { + if (string.IsNullOrWhiteSpace(path) || !SafeDirectoryExists(path)) continue; + _allItems.Add(MakeFolderItem(path, PathHelper.GetName(path))); + } + EmptyMessage = null; + } + } + + private static bool SafeDirectoryExists(string path) + { + try { return Directory.Exists(path); } catch { return false; } + } + + private static ExplorerItem MakeFolderItem(string path, string displayName) + => new(new FileEntry + { + Name = displayName, + FullPath = path, + IsDirectory = true, + ModifiedUtc = DateTime.UtcNow, + Attributes = FileAttributes.Directory + }) + { DisplayName = displayName }; + + private void RecordRecent(string path) + { + var recent = _settings.RecentFolders; + recent.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase)); + recent.Insert(0, path); + if (recent.Count > 20) recent.RemoveRange(20, recent.Count - 20); + } + + // ── 面包屑 ────────────────────────────────────────────────────────────── + private void BuildBreadcrumbs(NavigationLocation location) + { + var segments = new List(); + if (location.IsVirtual) + { + var parent = location.Kind switch + { + LocationKind.ThisPc or LocationKind.RecycleBin or LocationKind.Network => NavigationLocation.ThisPc, + _ => null + }; + if (parent is not null) segments.Add(new BreadcrumbSegment(parent.DisplayName, parent.Path, false)); + segments.Add(new BreadcrumbSegment(location.DisplayName, location.Path, false)); + Breadcrumbs = new ObservableCollection(segments); + return; + } + + var path = PathHelper.NormalizeDisplay(location.Path); + var root = Path.GetPathRoot(path) ?? path; + segments.Add(new BreadcrumbSegment("此电脑", KnownFolders.ThisPcParsingName, false)); + + var driveName = root.TrimEnd('\\'); + try + { + var drive = new DriveInfo(root); + var label = drive.IsReady ? drive.VolumeLabel : string.Empty; + driveName = string.IsNullOrWhiteSpace(label) ? $"本地磁盘 ({root.TrimEnd('\\')})" : $"{label} ({root.TrimEnd('\\')})"; + } + catch + { + // 使用默认显示名 + } + segments.Add(new BreadcrumbSegment(driveName, root, true)); + + var remainder = path[root.Length..].Trim('\\'); + if (remainder.Length > 0) + { + var accumulated = root; + foreach (var part in remainder.Split(['\\'], StringSplitOptions.RemoveEmptyEntries)) + { + accumulated = Path.Combine(accumulated, part); + segments.Add(new BreadcrumbSegment(part, accumulated, false)); + } + } + + Breadcrumbs = new ObservableCollection(segments); + } + + // ── 排序 / 过滤 ───────────────────────────────────────────────────────── + public void ApplySortAndRefresh() + { + var spec = Sort; + var dir = spec.Direction == SortDirection.Ascending ? 1 : -1; + var comparer = BuildComparer(spec.Column); + + _allItems.Sort((a, b) => + { + // 资源管理器行为:文件夹始终排在文件前面(按名称/类型排序时) + if (spec.Column is SortColumn.Name or SortColumn.Type) + { + if (a.IsDirectory != b.IsDirectory) return a.IsDirectory ? -1 : 1; + } + var result = comparer(a, b); + if (result == 0) result = NaturalStringComparer.Instance.Compare(a.Name, b.Name); + return result * dir; + }); + + var refreshed = new ObservableCollection(_allItems); + Items = refreshed; + UpdateStatusText(); + } + + private static Comparison BuildComparer(SortColumn column) => column switch + { + SortColumn.Name => (a, b) => NaturalStringComparer.Instance.Compare(a.DisplayName, b.DisplayName), + SortColumn.DateModified => (a, b) => a.Entry.ModifiedUtc.CompareTo(b.Entry.ModifiedUtc), + SortColumn.DateCreated => (a, b) => a.Entry.CreatedUtc.CompareTo(b.Entry.CreatedUtc), + SortColumn.Size => (a, b) => a.Size.CompareTo(b.Size), + SortColumn.Type => (a, b) => string.Compare(a.TypeText, b.TypeText, StringComparison.CurrentCultureIgnoreCase), + SortColumn.Extension => (a, b) => string.Compare(a.Extension, b.Extension, StringComparison.OrdinalIgnoreCase), + SortColumn.Path => (a, b) => string.Compare(a.FullPath, b.FullPath, StringComparison.OrdinalIgnoreCase), + SortColumn.Attributes => (a, b) => ((int)a.Entry.Attributes).CompareTo((int)b.Entry.Attributes), + _ => (a, b) => NaturalStringComparer.Instance.Compare(a.DisplayName, b.DisplayName) + }; + + private string BuildDisplayName(FileEntry entry) + { + if (entry.IsDirectory || _settings.ShowFileExtensions) return entry.Name; + var idx = entry.Name.LastIndexOf('.'); + return idx > 0 ? entry.Name[..idx] : entry.Name; + } + + public void RebuildDisplayNames() + { + foreach (var item in _allItems) item.DisplayName = BuildDisplayName(item.Entry); + } + + private void PrimeIcons(int count) + { + var take = Math.Min(count, Items.Count); + for (var i = 0; i < take; i++) _visuals.RequestIcon(Items[i]); + } + + // ── 命令 ──────────────────────────────────────────────────────────────── + private bool CanGoBack() => History.CanGoBack; + private bool CanGoForward() => History.CanGoForward; + private bool CanGoUp() => !string.IsNullOrEmpty(CurrentPath) && Location.Kind is LocationKind.Folder or LocationKind.Drive; + + [RelayCommand(CanExecute = nameof(CanGoBack))] + private async Task BackAsync() + { + var target = History.Back(); + if (target is null) return; + await NavigateAsync(target, addToHistory: false); + } + + [RelayCommand(CanExecute = nameof(CanGoForward))] + private async Task ForwardAsync() + { + var target = History.Forward(); + if (target is null) return; + await NavigateAsync(target, addToHistory: false); + } + + [RelayCommand(CanExecute = nameof(CanGoUp))] + private async Task UpAsync() + { + var parent = PathHelper.GetParent(CurrentPath); + if (string.Equals(parent, CurrentPath, StringComparison.OrdinalIgnoreCase)) return; + await NavigateAsync(NavigationLocation.FromPath(parent, LocationKind.Folder)); + } + + [RelayCommand] + private async Task RefreshAsync() + { + if (Location.IsVirtual) await NavigateAsync(Location, addToHistory: false); + else await LoadFolderAsync(CurrentPath, CancellationToken.None); + } + + [RelayCommand] + private async Task NavigatePathAsync(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return; + path = path.Trim().Trim('"'); + if (path.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || path.StartsWith("::", StringComparison.Ordinal)) + { + await NavigateAsync(new NavigationLocation(LocationKind.Folder, path, path, "\uE8B7")); + return; + } + if (SafeDirectoryExists(path)) + { + await NavigateAsync(NavigationLocation.FromPath(path)); + return; + } + ErrorOccurred?.Invoke(this, $"找不到路径:{path}"); + AddressText = CurrentPath; + } + + [RelayCommand] + public async Task OpenItemAsync(ExplorerItem? item) + { + if (item is null) return; + if (item.IsDirectory) + { + if (_settings.OpenFoldersInNewTab) + { + OpenInNewTabRequested?.Invoke(this, NavigationLocation.FromPath(item.FullPath)); + return; + } + await NavigateAsync(NavigationLocation.FromPath(item.FullPath)); + return; + } + + if (!ShellActions.Open(item.FullPath)) + ErrorOccurred?.Invoke(this, $"无法打开:{item.FullPath}"); + } + + [RelayCommand] + private void OpenInNewTab(ExplorerItem? item) + { + if (item is null || !item.IsDirectory) return; + OpenInNewTabRequested?.Invoke(this, NavigationLocation.FromPath(item.FullPath)); + } + + [RelayCommand] + private async Task NewFolderAsync() + { + if (Location.Kind is not (LocationKind.Folder or LocationKind.Drive)) return; + var name = "新建文件夹"; + var path = Path.Combine(CurrentPath, name); + var suffix = 2; + while (SafeDirectoryExists(path)) path = Path.Combine(CurrentPath, $"{name} ({suffix++})"); + + try + { + Directory.CreateDirectory(path); + } + catch (Exception ex) + { + ErrorOccurred?.Invoke(this, $"无法新建文件夹:{ex.Message}"); + return; + } + + await RefreshAsync(); + var created = _allItems.FirstOrDefault(i => string.Equals(i.FullPath, path, StringComparison.OrdinalIgnoreCase)); + if (created is not null) + { + created.IsPending = true; + ScrollIntoViewRequested?.Invoke(this, created); + StartRename(created); + } + } + + [RelayCommand] + private void StartRename(ExplorerItem? item) + { + if (item is null) return; + foreach (var other in _allItems) other.IsRenaming = false; + item.RenameText = item.DisplayName; + item.IsRenaming = true; + RenameRequested?.Invoke(this, item); + } + + [RelayCommand] + private async Task CommitRenameAsync(ExplorerItem? item) + { + if (item is null || !item.IsRenaming) return; + var newName = item.RenameText.Trim(); + item.IsRenaming = false; + + if (string.IsNullOrWhiteSpace(newName) || newName == item.DisplayName) + { + if (item.IsPending) await RefreshAsync(); + return; + } + + if (newName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + ErrorOccurred?.Invoke(this, "名称中不能包含下列字符:\\ / : * ? \" < > |"); + return; + } + + if (item.IsPending) + { + // 新建的占位项:直接重命名(并允许改名后自动补扩展名) + try + { + var target = Path.Combine(PathHelper.GetParent(item.FullPath), newName); + if (SafeDirectoryExists(item.FullPath)) Directory.Move(item.FullPath, target); + else File.Move(item.FullPath, target); + } + catch (Exception ex) + { + ErrorOccurred?.Invoke(this, $"重命名失败:{ex.Message}"); + } + await RefreshAsync(); + return; + } + + _operations.EnqueueRename(item.FullPath, newName); + await Task.Delay(180); + await RefreshAsync(); + } + + [RelayCommand] + private void CancelRename(ExplorerItem? item) + { + if (item is null) return; + item.IsRenaming = false; + if (item.IsPending) _ = RefreshAsync(); + } + + [RelayCommand] + private void CopySelected(IReadOnlyList? items) + { + var paths = SelectedPaths(items); + if (paths.Count == 0) return; + _clipboard.Set(paths, cut: false); + ShellActions.SetClipboardFiles(paths, cut: false); + UpdateStatusText(); + } + + [RelayCommand] + private void CutSelected(IReadOnlyList? items) + { + var paths = SelectedPaths(items); + if (paths.Count == 0) return; + _clipboard.Set(paths, cut: true); + ShellActions.SetClipboardFiles(paths, cut: true); + UpdateStatusText(); + } + + private bool CanPaste() => _clipboard.Paths.Count > 0 && Location.Kind is LocationKind.Folder or LocationKind.Drive; + + [RelayCommand(CanExecute = nameof(CanPaste))] + private async Task PasteAsync() + { + var paths = _clipboard.Paths; + if (paths.Count == 0) return; + var destination = CurrentPath; + + if (_clipboard.IsCut) _operations.EnqueueMove(paths, destination); + else _operations.EnqueueCopy(paths, destination); + + // 等待作业真正开始并落盘后再刷新(作业是异步的,这里给一个短暂延迟并依赖队列事件) + await Task.Delay(400); + await RefreshAsync(); + } + + [RelayCommand] + private void CopyPathSelected(IReadOnlyList? items) + { + var paths = SelectedPaths(items); + if (paths.Count == 0) return; + var package = new Windows.ApplicationModel.DataTransfer.DataPackage(); + package.SetText(string.Join(Environment.NewLine, paths)); + Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package); + UpdateStatusText("已复制路径"); + } + + private bool CanUndo() => _operations.CanUndo; + + [RelayCommand(CanExecute = nameof(CanUndo))] + private async Task UndoAsync() + { + var result = await _operations.UndoAsync(); + if (!result.Success) ErrorOccurred?.Invoke(this, result.Error ?? "撤销失败"); + await RefreshAsync(); + } + + [RelayCommand] + private async Task DeleteSelectedAsync(IReadOnlyList? items) + { + var paths = SelectedPaths(items); + if (paths.Count == 0) return; + if (IsRecycleBin) { await DeleteFromRecycleBinAsync(paths); return; } + _operations.EnqueueDelete(paths, permanent: !_settings.DeleteToRecycleBin); + await Task.Delay(300); + await RefreshAsync(); + } + + [RelayCommand] + private async Task PermanentDeleteSelectedAsync(IReadOnlyList? items) + { + var paths = SelectedPaths(items); + if (paths.Count == 0) return; + if (IsRecycleBin) { await DeleteFromRecycleBinAsync(paths); return; } + _operations.EnqueueDelete(paths, permanent: true); + await Task.Delay(300); + await RefreshAsync(); + } + + private async Task DeleteFromRecycleBinAsync(IReadOnlyList recyclePaths) + { + foreach (var path in recyclePaths) + { + try + { + if (Directory.Exists(path)) Directory.Delete(path, recursive: true); + else if (File.Exists(path)) File.Delete(path); + var meta = Path.Combine(Path.GetDirectoryName(path)!, "$I" + Path.GetFileName(path)[2..]); + if (File.Exists(meta)) File.Delete(meta); + } + catch (Exception ex) + { + ErrorOccurred?.Invoke(this, $"无法彻底删除:{ex.Message}"); + } + } + await RefreshAsync(); + } + + private bool CanRestore() => IsRecycleBin; + + [RelayCommand(CanExecute = nameof(CanRestore))] + private async Task RestoreSelectedAsync(IReadOnlyList? items) + { + var paths = SelectedPaths(items); + if (paths.Count == 0) return; + var moves = new List<(string From, string To)>(); + foreach (var path in paths) + { + if (!_recycleOriginalPaths.TryGetValue(path, out var original)) continue; + moves.Add((path, original)); + } + if (moves.Count == 0) return; + + await Task.Run(() => + { + foreach (var (from, to) in moves) + { + try + { + var dir = Path.GetDirectoryName(to); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + if (Directory.Exists(from)) Directory.Move(from, to); + else File.Move(from, to, overwrite: false); + } + catch (Exception ex) + { + ErrorOccurred?.Invoke(this, $"还原失败({Path.GetFileName(to)}):{ex.Message}"); + } + } + }); + await RefreshAsync(); + } + + [RelayCommand] + private async Task EmptyRecycleBinAsync() + { + if (!IsRecycleBin) return; + var paths = _allItems.Select(i => i.FullPath).ToList(); + await DeleteFromRecycleBinAsync(paths); + UpdateStatusText("回收站已清空"); + } + + [RelayCommand] + private void OpenSelected(IReadOnlyList? items) + { + var first = items?.FirstOrDefault(); + if (first is not null) _ = OpenItemAsync(first); + } + + [RelayCommand] + private void ShowPropertiesSelected(IReadOnlyList? items) + { + var paths = SelectedPaths(items); + if (paths.Count == 0) return; + PropertiesRequested?.Invoke(this, paths); + } + + public event EventHandler>? PropertiesRequested; + + [RelayCommand] + private void RevealSelected(IReadOnlyList? items) + { + var first = items?.FirstOrDefault(); + if (first is not null) ShellActions.RevealInExplorer(first.FullPath); + } + + [RelayCommand] + private void OpenWithSelected(IReadOnlyList? items) + { + var first = items?.FirstOrDefault(); + if (first is not null && !first.IsDirectory) ShellActions.OpenWith(first.FullPath); + } + + [RelayCommand] + private void SortBy(SortColumn column) + { + Sort = Sort.Toggle(column); + PersistViewState(); + ApplySortAndRefresh(); + } + + [RelayCommand] + private void SetViewMode(string? mode) + { + if (string.IsNullOrEmpty(mode)) return; + ViewMode = mode switch + { + "ExtraLargeIcons" => ViewMode.ExtraLargeIcons, + "LargeIcons" => ViewMode.LargeIcons, + "MediumIcons" => ViewMode.MediumIcons, + "SmallIcons" => ViewMode.SmallIcons, + "List" => ViewMode.List, + "Tiles" => ViewMode.Tiles, + "Content" => ViewMode.Content, + _ => ViewMode.Details + }; + PersistViewState(); + OnPropertyChanged(nameof(IsDetailsView)); + OnPropertyChanged(nameof(IsIconsView)); + OnPropertyChanged(nameof(IconSize)); + OnPropertyChanged(nameof(TileWidth)); + OnPropertyChanged(nameof(TileHeight)); + OnPropertyChanged(nameof(RowHeight)); + PrimeIcons(160); + } + + [RelayCommand] + private void CycleViewMode() + { + var order = new[] { ViewMode.ExtraLargeIcons, ViewMode.LargeIcons, ViewMode.MediumIcons, ViewMode.SmallIcons, ViewMode.List, ViewMode.Details }; + var index = Array.IndexOf(order, ViewMode); + SetViewMode(order[(index + 1) % order.Length].ToString()); + } + + [RelayCommand] + private void ToggleHidden() + { + _settings.ShowHiddenFiles = !_settings.ShowHiddenFiles; + ShowHidden = _settings.ShowHiddenFiles; + SettingsChanged?.Invoke(this, EventArgs.Empty); + _ = RefreshAsync(); + } + + public event EventHandler? SettingsChanged; + + public bool IsDetailsView => ViewMode == ViewMode.Details; + public bool IsIconsView => ViewMode is ViewMode.ExtraLargeIcons or ViewMode.LargeIcons or ViewMode.MediumIcons or ViewMode.SmallIcons or ViewMode.Tiles; + + public int IconSize => ViewMode switch + { + ViewMode.ExtraLargeIcons => 256, + ViewMode.LargeIcons => 96, + ViewMode.MediumIcons => 48, + ViewMode.SmallIcons => 20, + ViewMode.Tiles => 64, + ViewMode.Content => 32, + _ => 16 + }; + + private void PersistViewState() + { + var state = _settings.GetFolderView(Location.Path); + state.ViewMode = ViewMode; + state.SortColumn = Sort.Column; + state.SortDirection = Sort.Direction; + state.GroupBy = GroupBy; + _settings.Save(); + } + + // ── 搜索 ──────────────────────────────────────────────────────────────── + public async Task SearchAsync(string? text) + { + SearchText = text ?? string.Empty; + _searchCts?.Cancel(); + + if (string.IsNullOrWhiteSpace(SearchText)) + { + IsSearchActive = false; + SearchResults = []; + SearchStatusText = string.Empty; + _navigationCts?.Cancel(); + await RefreshAsync(); + return; + } + + var cts = new CancellationTokenSource(); + _searchCts = cts; + try + { + await Task.Delay(110, cts.Token); + } + catch (OperationCanceledException) + { + return; + } + + IsSearching = true; + IsSearchActive = true; + var query = SearchQueryParser.Parse(SearchText); + var scope = _settings.SearchScope; + // 即使选了"全盘",也把当前文件夹传下去:索引不可用时它是实时扫描的起点(否则会从盘根扫) + var basePath = Location.Kind is LocationKind.Folder or LocationKind.Drive ? CurrentPath : null; + + try + { + var outcome = await _search.SearchAsync(query, scope, basePath, 4000, cts.Token); + if (cts.IsCancellationRequested) return; + + var collection = new ObservableCollection(); + var count = 0; + foreach (var hit in outcome.Hits) + { + collection.Add(new SearchResultItem(hit)); + if (++count % 400 == 0) await Task.Yield(); + } + SearchResults = collection; + + var source = outcome.UsedIndex ? "NTFS 索引" : "实时扫描"; + var suffix = outcome.Truncated ? "(结果已截断)" : string.Empty; + SearchStatusText = $"{outcome.Hits.Count:N0} 项结果 · {outcome.Elapsed.TotalMilliseconds:0} 毫秒 · {source}{suffix}"; + NoteMessage = outcome.Note; + PrimeSearchIcons(60); + } + catch (OperationCanceledException) + { + // 输入变化时正常取消 + } + finally + { + IsSearching = false; + } + } + + [RelayCommand] + private Task ClearSearchAsync() => SearchAsync(string.Empty); + + [RelayCommand] + public async Task OpenSearchResultAsync(SearchResultItem? item) + { + if (item is null) return; + if (item.IsDirectory) + { + await NavigateAsync(NavigationLocation.FromPath(item.FullPath)); + return; + } + if (!ShellActions.Open(item.FullPath)) + ErrorOccurred?.Invoke(this, $"无法打开:{item.FullPath}"); + } + + [RelayCommand] + private async Task OpenSearchResultFolderAsync(SearchResultItem? item) + { + if (item is null) return; + var dir = item.IsDirectory ? item.FullPath : item.Directory; + if (SafeDirectoryExists(dir)) await NavigateAsync(NavigationLocation.FromPath(dir)); + } + + private void PrimeSearchIcons(int count) + { + var take = Math.Min(count, SearchResults.Count); + for (var i = 0; i < take; i++) _visuals.RequestIcon(SearchResults[i]); + } + + // ── 拖放 / 批量移动 ───────────────────────────────────────────────────── + /// 把条目拖放到某个文件夹(同盘为移动,按住 Ctrl 为复制)。 + public async Task DropIntoAsync(IReadOnlyList paths, string destination, bool copy) + { + if (paths.Count == 0) return; + if (copy) _operations.EnqueueCopy(paths, destination); + else _operations.EnqueueMove(paths, destination); + await Task.Delay(300); + await RefreshAsync(); + } + + // ── 选择 / 状态栏 ─────────────────────────────────────────────────────── + public void OnSelectionChanged(IReadOnlyList selected) + { + foreach (var item in _allItems) item.IsSelected = false; + foreach (var item in selected) item.IsSelected = true; + UpdateSelectionText(selected); + RestoreSelectedCommand.NotifyCanExecuteChanged(); + } + + public int SelectionCount { get; private set; } + + private void UpdateSelectionText(IReadOnlyList selected) + { + SelectionCount = selected.Count; + if (selected.Count == 0) + { + SelectionText = string.Empty; + UpdateStatusText(); + return; + } + + long bytes = 0; + var unknown = false; + foreach (var item in selected) + { + if (item.IsDirectory) { unknown = true; continue; } + bytes += item.Size; + } + + var sizeText = bytes > 0 ? FileEntry.FormatSize(bytes) : null; + SelectionText = unknown + ? $"已选择 {selected.Count} 个项目" + : sizeText is null ? $"已选择 {selected.Count} 个项目" : $"已选择 {selected.Count} 个项目 · {sizeText}"; + StatusText = Items.Count == 0 ? SelectionText : $"{Items.Count} 个项目 {SelectionText}"; + } + + private void UpdateStatusText(string? overrideText = null) + { + if (overrideText is not null) + { + StatusText = overrideText; + return; + } + StatusText = Items.Count == 0 ? "此文件夹为空" : $"{Items.Count} 个项目"; + if (!string.IsNullOrEmpty(SelectionText) && Items.Count > 0) StatusText += $" {SelectionText}"; + } + + private static List SelectedPaths(IReadOnlyList? items) + => items is null ? [] : items.Where(i => i is not null).Select(i => i.FullPath).ToList(); + + public void Dispose() + { + _navigationCts?.Cancel(); + _searchCts?.Cancel(); + } +} diff --git a/ViewModels/ExplorerTabViewModel.cs b/ViewModels/ExplorerTabViewModel.cs new file mode 100644 index 0000000..7296515 --- /dev/null +++ b/ViewModels/ExplorerTabViewModel.cs @@ -0,0 +1,111 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using FluidExplorer.Navigation; +using FluidExplorer.Services; +using FluidExplorer.Services.FileSystem; +using FluidExplorer.Services.ItemVisuals; +using FluidExplorer.Services.Operations; +using FluidExplorer.Services.Search; +using Microsoft.UI.Dispatching; +using PathHelper = FluidExplorer.Services.FileSystem.PathHelper; + +namespace FluidExplorer.ViewModels; + +/// 一个标签页:包含主窗格,可选第二窗格(双窗格模式,搬文件不用来回切)。 +public sealed partial class ExplorerTabViewModel : ObservableObject +{ + private readonly IFileSystemService _fs; + private readonly SearchService _search; + private readonly IFileOperationService _operations; + private readonly ItemVisualService _visuals; + private readonly IFileClipboard _clipboard; + private readonly AppSettings _settings; + private readonly DispatcherQueue _ui; + + [ObservableProperty] private bool _isDualPane; + [ObservableProperty] private bool _isActivePaneSecondary; + [ObservableProperty] private string _header = "新标签页"; + [ObservableProperty] private string _glyph = "\uE8B7"; + + public ExplorerTabViewModel( + IFileSystemService fs, + SearchService search, + IFileOperationService operations, + ItemVisualService visuals, + IFileClipboard clipboard, + AppSettings settings, + DispatcherQueue ui, + NavigationLocation start) + { + _fs = fs; + _search = search; + _operations = operations; + _visuals = visuals; + _clipboard = clipboard; + _settings = settings; + _ui = ui; + + Primary = new ExplorerPaneViewModel(fs, search, operations, visuals, clipboard, settings, ui); + Primary.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ExplorerPaneViewModel.Title)) UpdateHeader(); + }; + Primary.SettingsChanged += (_, _) => UpdateHeader(); + _ = Primary.NavigateAsync(start); + UpdateHeader(); + } + + public ExplorerPaneViewModel Primary { get; } + + [ObservableProperty] private ExplorerPaneViewModel? _secondary; + + public ExplorerPaneViewModel ActivePane => IsDualPane && IsActivePaneSecondary && Secondary is not null ? Secondary : Primary; + + public ExplorerPaneViewModel EnsureSecondary() + { + if (Secondary is not null) return Secondary; + var pane = new ExplorerPaneViewModel(_fs, _search, _operations, _visuals, _clipboard, _settings, _ui) + { + ShowSidebar = false + }; + pane.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ExplorerPaneViewModel.Title)) UpdateHeader(); + }; + Secondary = pane; + return pane; + } + + public void ToggleDualPane() + { + if (IsDualPane) + { + IsDualPane = false; + IsActivePaneSecondary = false; + return; + } + + var secondary = EnsureSecondary(); + if (string.IsNullOrEmpty(secondary.CurrentPath) || secondary.CurrentPath.StartsWith("::", StringComparison.Ordinal)) + { + // 第二个窗格默认停在与主窗格同级的目录,便于对拖 + var parent = PathHelper.GetParent(Primary.CurrentPath); + _ = secondary.NavigateAsync(NavigationLocation.FromPath( + Directory.Exists(parent) ? parent : Primary.CurrentPath)); + } + IsDualPane = true; + } + + private void UpdateHeader() + { + Header = Primary.IsSearchActive && !string.IsNullOrWhiteSpace(Primary.SearchText) + ? $"搜索:{Primary.SearchText}" + : Primary.Title; + Glyph = Primary.Glyph; + } + + public void Dispose() + { + Primary.Dispose(); + Secondary?.Dispose(); + } +} diff --git a/ViewModels/JobRowViewModel.cs b/ViewModels/JobRowViewModel.cs new file mode 100644 index 0000000..1017048 --- /dev/null +++ b/ViewModels/JobRowViewModel.cs @@ -0,0 +1,64 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using FluidExplorer.Models; +using FluidExplorer.Services.Operations; + +namespace FluidExplorer.ViewModels; + +/// +/// 操作队列面板里的一行。作业对象是在后台线程上更新并抛 PropertyChanged 的, +/// 直接绑定会跨线程更新 UI 而崩溃,因此这里在 UI 线程维护一份快照,由定时器按 250ms 刷新。 +/// +public sealed partial class JobRowViewModel : ObservableObject +{ + [ObservableProperty] private string _title = string.Empty; + [ObservableProperty] private string _stateText = string.Empty; + [ObservableProperty] private string _progressText = string.Empty; + [ObservableProperty] private string _currentItem = string.Empty; + [ObservableProperty] private double _progress; + [ObservableProperty] private bool _isIndeterminate; + [ObservableProperty] private bool _canPause; + [ObservableProperty] private bool _canCancel; + [ObservableProperty] private bool _isFinished; + [ObservableProperty] private string _pauseGlyph = "\uE769"; + + public JobRowViewModel(FileOperationJob job) + { + Job = job; + Refresh(); + } + + public FileOperationJob Job { get; } + + public void Refresh() + { + Title = Job.Title; + StateText = Job.State switch + { + JobState.Queued => "排队中", + JobState.Running => "进行中", + JobState.Paused => "已暂停", + JobState.Completed => "已完成", + JobState.CompletedWithErrors => "已完成(部分出错)", + JobState.Cancelled => "已取消", + JobState.Failed => "失败", + _ => Job.State.ToString() + }; + + var parts = new List { $"{Job.CompletedItems:N0} / {Job.TotalItems:N0} 个项目" }; + if (Job.TotalBytes > 0) + parts.Add($"{FileEntry.FormatSize(Job.CompletedBytes)} / {FileEntry.FormatSize(Job.TotalBytes)}"); + if (Job.BytesPerSecond > 1) parts.Add($"{FileEntry.FormatSize((long)Job.BytesPerSecond)}/s"); + if (Job.Eta is { } eta && eta.TotalSeconds > 1 && eta.TotalHours < 24) parts.Add($"剩余 {eta:mm\\:ss}"); + ProgressText = string.Join(" · ", parts); + + CurrentItem = Job.CurrentItem ?? string.Empty; + if (!string.IsNullOrEmpty(Job.Error)) CurrentItem = Job.Error!; + + Progress = Job.Progress; + IsIndeterminate = Job.IsIndeterminate; + CanPause = Job.CanPause; + CanCancel = Job.CanCancel; + IsFinished = Job.IsFinished; + PauseGlyph = Job.State == JobState.Paused ? "\uE768" : "\uE769"; + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..29aeee1 --- /dev/null +++ b/ViewModels/MainViewModel.cs @@ -0,0 +1,500 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using FluidExplorer.Navigation; +using FluidExplorer.Services; +using FluidExplorer.Services.FileSystem; +using FluidExplorer.Services.Icons; +using FluidExplorer.Services.ItemVisuals; +using FluidExplorer.Services.Operations; +using FluidExplorer.Services.Search; +using FluidExplorer.Services.Shell; +using FluidExplorer.Helpers; +using Microsoft.UI.Dispatching; +using PathHelper = FluidExplorer.Services.FileSystem.PathHelper; + +namespace FluidExplorer.ViewModels; + +/// 主视图模型:标签页、侧边栏、索引状态、操作队列、设置。 +public sealed partial class MainViewModel : ObservableObject +{ + private readonly IFileSystemService _fs; + private readonly SearchService _search; + private readonly IFileOperationService _operations; + private readonly AppSettings _settings; + private readonly DispatcherQueue _ui; + + public MainViewModel( + IFileSystemService fs, + SearchService search, + IFileOperationService operations, + IIconService icons, + AppSettings settings, + DispatcherQueue ui) + { + _fs = fs; + _search = search; + _operations = operations; + _settings = settings; + _ui = ui; + + Clipboard = new FileClipboard(); + Visuals = new ItemVisualService(icons, ui); + + ShowHiddenFiles = settings.ShowHiddenFiles; + ShowSystemFiles = settings.ShowSystemFiles; + ShowFileExtensions = settings.ShowFileExtensions; + AlwaysShowCheckBoxes = settings.AlwaysShowCheckBoxes; + DeleteToRecycleBin = settings.DeleteToRecycleBin; + AnimationsEnabled = settings.AnimationsEnabled; + SearchInCurrentFolderOnly = settings.SearchScope == SearchScope.CurrentFolder; + ThemeMode = settings.ThemeMode; + + _operations.JobsChanged += (_, _) => RefreshJobs(); + _search.IndexStateChanged += (_, _) => _ui.Post(RefreshIndexState); + + _jobTimer = _ui.CreateTimer(); + _jobTimer.Interval = TimeSpan.FromMilliseconds(250); + _jobTimer.IsRepeating = true; + _jobTimer.Tick += OnJobTimerTick; + + BuildSidebar(); + RefreshIndexState(); + RefreshJobs(); + } + + public ItemVisualService Visuals { get; } + public IFileClipboard Clipboard { get; } + public AppSettings Settings => _settings; + + // ── 标签页 ────────────────────────────────────────────────────────────── + public ObservableCollection Tabs { get; } = []; + [ObservableProperty] private ExplorerTabViewModel? _selectedTab; + + public ExplorerPaneViewModel? ActivePane => SelectedTab?.ActivePane; + + public ExplorerTabViewModel AddTab(NavigationLocation? start = null, bool select = true) + { + var location = start ?? DefaultStartLocation(); + var tab = new ExplorerTabViewModel(_fs, _search, _operations, Visuals, Clipboard, _settings, _ui, location); + tab.PropertyChanged += (_, _) => { if (ReferenceEquals(tab, SelectedTab)) OnPropertyChanged(nameof(ActivePane)); }; + Tabs.Add(tab); + if (select) SelectedTab = tab; + SaveOpenTabs(); + return tab; + } + + [RelayCommand] + private void NewTab() => AddTab(); + + [RelayCommand] + private void CloseTab(ExplorerTabViewModel? tab) + { + tab ??= SelectedTab; + if (tab is null) return; + var index = Tabs.IndexOf(tab); + tab.Dispose(); + Tabs.Remove(tab); + if (Tabs.Count == 0) + { + AddTab(); + return; + } + SelectedTab = Tabs[Math.Clamp(index, 0, Tabs.Count - 1)]; + SaveOpenTabs(); + } + + [RelayCommand] + private void DuplicateTab(ExplorerTabViewModel? tab) + { + tab ??= SelectedTab; + if (tab is null) return; + AddTab(NavigationLocation.FromPath(tab.Primary.CurrentPath)); + } + + [RelayCommand] + private void ToggleDualPane() + { + SelectedTab?.ToggleDualPane(); + _settings.DualPane = SelectedTab?.IsDualPane ?? false; + _settings.Save(); + } + + partial void OnSelectedTabChanged(ExplorerTabViewModel? value) + { + OnPropertyChanged(nameof(ActivePane)); + if (value is not null) UpdateSidebarSelection(value.ActivePane.CurrentPath); + } + + private NavigationLocation DefaultStartLocation() + { + if (!string.IsNullOrWhiteSpace(_settings.DefaultStartPath) && Directory.Exists(_settings.DefaultStartPath)) + return NavigationLocation.FromPath(_settings.DefaultStartPath); + var downloads = KnownFolders.Downloads; + return Directory.Exists(downloads) ? NavigationLocation.FromPath(downloads) : NavigationLocation.Home; + } + + // ── 侧边栏 ────────────────────────────────────────────────────────────── + public ObservableCollection SidebarRoots { get; } = []; + [ObservableProperty] private SidebarNode? _selectedSidebarNode; + + private void BuildSidebar() + { + SidebarRoots.Clear(); + SidebarRoots.Add(SidebarNode.Create("主页", "\uE80F", NavigationLocation.Home)); + SidebarRoots.Add(SidebarNode.Create("图库", "\uE91B", NavigationLocation.Gallery)); + + if (_settings.PinnedFolders.Count == 0) + { + foreach (var path in new[] { KnownFolders.Desktop, KnownFolders.Downloads, KnownFolders.Documents, KnownFolders.Pictures }) + if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) _settings.PinnedFolders.Add(path); + } + + var quick = SidebarNode.Create("快速访问", "\uE8B7", NavigationLocation.Home, expandable: true); + quick.IsExpanded = true; + foreach (var path in _settings.PinnedFolders) + { + if (!Directory.Exists(path)) continue; + quick.Children.Add(SidebarNode.Create(PathHelper.GetName(path), "\uE8B7", NavigationLocation.FromPath(path), canPin: true)); + } + SidebarRoots.Add(quick); + + var thisPc = SidebarNode.Create("此电脑", "\uE977", NavigationLocation.ThisPc, expandable: true); + thisPc.IsExpanded = true; + foreach (var drive in DriveItem.Enumerate()) + { + thisPc.Children.Add(SidebarNode.Create(drive.DisplayName, drive.Glyph, + new NavigationLocation(LocationKind.Drive, drive.RootPath, drive.DisplayName, drive.Glyph))); + } + SidebarRoots.Add(thisPc); + + SidebarRoots.Add(SidebarNode.Create("网络", "\uE968", NavigationLocation.Network)); + SidebarRoots.Add(SidebarNode.Create("回收站", "\uE74D", NavigationLocation.RecycleBin)); + } + + public void UpdateSidebarSelection(string path) + { + if (string.IsNullOrEmpty(path)) return; + foreach (var node in EnumerateNodes(SidebarRoots)) + { + var match = node.Location.Kind switch + { + LocationKind.Home or LocationKind.Gallery or LocationKind.ThisPc or LocationKind.Network or LocationKind.RecycleBin + => string.Equals(node.Location.Path, path, StringComparison.OrdinalIgnoreCase), + LocationKind.Drive => string.Equals(node.Location.Path.TrimEnd('\\'), path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase), + _ => false + }; + if (match) + { + SelectedSidebarNode = node; + return; + } + } + } + + private static IEnumerable EnumerateNodes(IEnumerable nodes) + { + foreach (var node in nodes) + { + yield return node; + foreach (var child in EnumerateNodes(node.Children)) yield return child; + } + } + + [RelayCommand] + private async Task NavigateSidebarAsync(SidebarNode? node) + { + if (node is null) return; + var pane = ActivePane ?? AddTab(node.Location).Primary; + await pane.NavigateAsync(node.Location); + UpdateSidebarSelection(node.Location.Path); + } + + [RelayCommand] + private void PinFolder() + { + var pane = ActivePane; + if (pane is null || pane.Location.Kind is not (LocationKind.Folder or LocationKind.Drive)) return; + if (_settings.PinnedFolders.Contains(pane.CurrentPath, StringComparer.OrdinalIgnoreCase)) return; + _settings.PinnedFolders.Add(pane.CurrentPath); + _settings.Save(); + BuildSidebar(); + } + + [RelayCommand] + private void UnpinFolder(SidebarNode? node) + { + if (node is null) return; + _settings.PinnedFolders.RemoveAll(p => string.Equals(p, node.Location.Path, StringComparison.OrdinalIgnoreCase)); + _settings.Save(); + BuildSidebar(); + } + + // ── 索引状态 ──────────────────────────────────────────────────────────── + [ObservableProperty] private string _indexSummary = "索引:未启动"; + [ObservableProperty] private bool _isIndexing; + [ObservableProperty] private double _indexProgress; + [ObservableProperty] private bool _indexNeedsElevation; + [ObservableProperty] private string _indexDetail = string.Empty; + + private void RefreshIndexState() + { + var state = _search.AggregateState; + var count = _search.TotalIndexedEntries; + IndexDetail = _search.AllIndexes.FirstOrDefault() is { } first + ? first.GetType().Name + : string.Empty; + + switch (state) + { + case IndexState.NotStarted: + IndexSummary = "索引:未启动(搜索将退化为实时扫描)"; + IsIndexing = false; + break; + case IndexState.Building: + IndexSummary = $"索引:正在建立…(已索引 {count:N0} 项)"; + IsIndexing = true; + break; + case IndexState.RequiresElevation: + IndexSummary = "索引:需要管理员权限才能读取 NTFS 主文件表"; + IsIndexing = false; + IndexNeedsElevation = true; + break; + case IndexState.Failed: + IndexSummary = "索引:不可用(该卷不是 NTFS)"; + IsIndexing = false; + break; + default: + IndexSummary = $"索引:{count:N0} 项 · 已就绪"; + IsIndexing = false; + IndexNeedsElevation = false; + break; + } + } + + [RelayCommand] + private async Task BuildIndexAsync() + { + if (IsIndexing) return; + IsIndexing = true; + IndexSummary = "索引:正在建立…"; + + var volumes = DriveItem.Enumerate() + .Where(d => d.DriveType == 3 && d.IsReady) + .Select(d => d.RootPath) + .ToList(); + if (volumes.Count == 0) volumes.Add(Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\"); + + _settings.IndexedVolumes = volumes; + _settings.Save(); + + var progress = new Progress<(string Volume, double Progress)>(p => _ui.Post(() => + { + IndexProgress = p.Progress; + IndexSummary = $"索引:正在读取 {p.Volume} … {p.Progress:P0}"; + })); + + try + { + await _search.BuildIndexesAsync(volumes, progress, CancellationToken.None); + } + catch (Exception ex) + { + IndexSummary = $"索引:失败({ex.Message})"; + } + finally + { + IsIndexing = false; + RefreshIndexState(); + } + } + + [RelayCommand] + private void RestartElevated() + { + try + { + var exe = Environment.ProcessPath; + if (string.IsNullOrEmpty(exe)) return; + Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true, Verb = "runas" }); + Microsoft.UI.Xaml.Application.Current.Exit(); + } + catch + { + // 用户拒绝了 UAC + } + } + + // ── 操作队列 ──────────────────────────────────────────────────────────── + public ObservableCollection JobRows { get; } = []; + [ObservableProperty] private bool _isQueueExpanded; + [ObservableProperty] private int _activeJobCount; + private readonly DispatcherQueueTimer _jobTimer; + + private void RefreshJobs() + { + _ui.Post(() => + { + JobRows.Clear(); + foreach (var job in _operations.Jobs) JobRows.Add(new JobRowViewModel(job)); + ActiveJobCount = JobRows.Count(j => !j.IsFinished); + OnPropertyChanged(nameof(HasJobs)); + + // 有活动作业时以 250ms 刷新快照(作业本身在后台线程更新,不能直接绑 UI) + if (ActiveJobCount > 0) _jobTimer.Start(); + else _jobTimer.Stop(); + }); + } + + private void OnJobTimerTick(DispatcherQueueTimer sender, object args) + { + var anyActive = false; + foreach (var row in JobRows) + { + row.Refresh(); + if (!row.IsFinished) anyActive = true; + } + ActiveJobCount = JobRows.Count(r => !r.IsFinished); + if (!anyActive) sender.Stop(); + } + + public bool HasJobs => JobRows.Count > 0; + + [RelayCommand] + private void PauseJob(JobRowViewModel? row) + { + if (row is null) return; + if (row.Job.State == JobState.Paused) _operations.Resume(row.Job.Id); + else _operations.Pause(row.Job.Id); + } + + [RelayCommand] + private void CancelJob(JobRowViewModel? row) + { + if (row is null) return; + _operations.Cancel(row.Job.Id); + } + + [RelayCommand] + private void ClearFinishedJobs() + { + _operations.ClearFinished(); + RefreshJobs(); + } + + [RelayCommand] + private async Task UndoAsync() + { + var result = await _operations.UndoAsync(); + if (!result.Success) ErrorMessage = result.Error; + await (ActivePane?.RefreshCommand.ExecuteAsync(null) ?? Task.CompletedTask); + } + + [ObservableProperty] private string? _errorMessage; + + // ── 设置(改动即时生效) ──────────────────────────────────────────────── + [ObservableProperty] private bool _showHiddenFiles; + [ObservableProperty] private bool _showSystemFiles; + [ObservableProperty] private bool _showFileExtensions; + [ObservableProperty] private bool _alwaysShowCheckBoxes; + [ObservableProperty] private bool _deleteToRecycleBin; + [ObservableProperty] private bool _animationsEnabled; + [ObservableProperty] private bool _searchInCurrentFolderOnly; + [ObservableProperty] private AppThemeMode _themeMode; + + partial void OnShowHiddenFilesChanged(bool value) + { + _settings.ShowHiddenFiles = value; + _settings.Save(); + _ = ActivePane?.RefreshCommand.ExecuteAsync(null); + } + + partial void OnShowSystemFilesChanged(bool value) + { + _settings.ShowSystemFiles = value; + _settings.Save(); + _ = ActivePane?.RefreshCommand.ExecuteAsync(null); + } + + partial void OnShowFileExtensionsChanged(bool value) + { + _settings.ShowFileExtensions = value; + _settings.Save(); + foreach (var pane in AllPanes()) pane.RebuildDisplayNames(); + } + + partial void OnAlwaysShowCheckBoxesChanged(bool value) + { + _settings.AlwaysShowCheckBoxes = value; + _settings.Save(); + } + + partial void OnDeleteToRecycleBinChanged(bool value) + { + _settings.DeleteToRecycleBin = value; + _settings.Save(); + } + + partial void OnAnimationsEnabledChanged(bool value) + { + _settings.AnimationsEnabled = value; + _settings.Save(); + AnimationsToggled?.Invoke(this, value); + } + + public event EventHandler? AnimationsToggled; + public event EventHandler? ThemeModeChanged; + + partial void OnThemeModeChanged(AppThemeMode value) + { + _settings.ThemeMode = value; + _settings.Save(); + ThemeModeChanged?.Invoke(this, value); + } + + partial void OnSearchInCurrentFolderOnlyChanged(bool value) + { + _settings.SearchScope = value ? SearchScope.CurrentFolder : SearchScope.Global; + _settings.Save(); + } + + private IEnumerable AllPanes() + { + foreach (var tab in Tabs) + { + yield return tab.Primary; + if (tab.Secondary is not null) yield return tab.Secondary; + } + } + + public void SaveOpenTabs() + { + _settings.OpenTabs = Tabs + .Select(t => t.Primary.CurrentPath) + .Where(p => !string.IsNullOrEmpty(p) && !p.StartsWith("::", StringComparison.Ordinal)) + .ToList(); + _settings.Save(); + } + + /// 启动:恢复上次的标签页并建立索引。 + public async Task InitializeAsync() + { + if (_settings.RestoreTabsOnStartup && _settings.OpenTabs.Count > 0) + { + foreach (var path in _settings.OpenTabs.Take(8)) + { + if (Directory.Exists(path)) AddTab(NavigationLocation.FromPath(path), select: false); + } + } + if (Tabs.Count == 0) AddTab(); + SelectedTab = Tabs[0]; + + if (_settings.DualPane) SelectedTab?.ToggleDualPane(); + + if (_settings.IndexOnStartup) + { + await BuildIndexAsync(); + } + } +} diff --git a/ViewModels/SidebarNode.cs b/ViewModels/SidebarNode.cs new file mode 100644 index 0000000..cb01680 --- /dev/null +++ b/ViewModels/SidebarNode.cs @@ -0,0 +1,97 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using FluidExplorer.Navigation; +using FluidExplorer.Services.FileSystem; +using FluidExplorer.Services.Search; +using FluidExplorer.Services.Shell; +using Microsoft.UI.Xaml.Media; + +namespace FluidExplorer.ViewModels; + +/// 侧边栏的一个节点(可展开的"此电脑"、可收藏的文件夹、驱动器等)。 +public sealed partial class SidebarNode : ObservableObject +{ + [ObservableProperty] private bool _isExpanded; + [ObservableProperty] private bool _isSelected; + + public SidebarNode(string displayName, string glyph, NavigationLocation location, bool isExpandable = false) + { + DisplayName = displayName; + Glyph = glyph; + Location = location; + IsExpandable = isExpandable; + } + + public string DisplayName { get; } + public string Glyph { get; } + public NavigationLocation Location { get; } + public bool IsExpandable { get; } + public bool CanPin { get; init; } + + public ObservableCollection Children { get; } = []; + + public bool HasUnrealizedChildren { get; set; } + + /// 当前文件夹是否属于该节点(用于侧边栏高亮,对齐资源管理器)。 + public bool Contains(string path) + { + if (string.IsNullOrEmpty(path)) return false; + if (Location.Kind is LocationKind.ThisPc or LocationKind.RecycleBin or LocationKind.Network or LocationKind.Home) + return string.Equals(Location.Path, path, StringComparison.OrdinalIgnoreCase); + var root = Location.Path.TrimEnd('\\'); + return path.StartsWith(root, StringComparison.OrdinalIgnoreCase); + } + + public static SidebarNode Create(string name, string glyph, NavigationLocation location, bool expandable = false, bool canPin = false) + => new(name, glyph, location, expandable) { CanPin = canPin }; +} + +/// 面包屑的一段。 +public sealed partial class BreadcrumbSegment : ObservableObject +{ + public BreadcrumbSegment(string displayName, string path, bool isDriveRoot) + { + DisplayName = displayName; + Path = path; + IsDriveRoot = isDriveRoot; + } + + public string DisplayName { get; } + public string Path { get; } + public bool IsDriveRoot { get; } +} + +/// 搜索结果列表里的一行。 +public sealed partial class SearchResultItem : ObservableObject +{ + private ImageSource? _icon; + private ImageSource? _thumbnail; + + public SearchResultItem(SearchHit hit) => Hit = hit; + + public SearchHit Hit { get; } + public string Name => Hit.Name; + public string Directory => Hit.Directory; + public string FullPath => Hit.Path; + public bool IsDirectory => Hit.IsDirectory; + public long Size => Hit.Size; + public string SizeText => Hit.Size < 0 ? "—" : Hit.IsDirectory ? string.Empty : Models.FileEntry.FormatSize(Hit.Size); + public string ModifiedText => Hit.ModifiedUtc == DateTime.MinValue || Hit.ModifiedUtc == default + ? "—" + : Hit.ModifiedUtc.ToLocalTime().ToString("yyyy/MM/dd HH:mm"); + public string TypeText => Hit.IsDirectory ? "文件夹" : TypeNameResolver.GetTypeName(Hit.Extension, false); + + public ImageSource? Icon + { + get => _icon; + set { if (!ReferenceEquals(_icon, value)) { _icon = value; OnPropertyChanged(); } } + } + + public ImageSource? Thumbnail + { + get => _thumbnail; + set { if (!ReferenceEquals(_thumbnail, value)) { _thumbnail = value; OnPropertyChanged(); } } + } + + public DateTime SortModified => Hit.ModifiedUtc; +} diff --git a/Views/ExplorerPaneView.xaml b/Views/ExplorerPaneView.xaml new file mode 100644 index 0000000..4d834b8 --- /dev/null +++ b/Views/ExplorerPaneView.xaml @@ -0,0 +1,508 @@ + + + + + + 30 + 0 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/ExplorerPaneView.xaml.cs b/Views/ExplorerPaneView.xaml.cs new file mode 100644 index 0000000..56fbaf7 --- /dev/null +++ b/Views/ExplorerPaneView.xaml.cs @@ -0,0 +1,800 @@ +using System.Collections.Specialized; +using FluidExplorer.Helpers; +using FluidExplorer.Models; +using FluidExplorer.Navigation; +using FluidExplorer.Services; +using FluidExplorer.Services.Operations; +using FluidExplorer.Services.Shell; +using FluidExplorer.ViewModels; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Windows.ApplicationModel.DataTransfer; +using Windows.System; +using Windows.UI.Core; + +namespace FluidExplorer.Views; + +/// +/// 单个浏览窗格的视图:命令栏 + 地址栏 + 内容区 + 状态栏。 +/// 所有交互都委托给 ,视图本身不碰文件系统。 +/// +public sealed partial class ExplorerPaneView : UserControl +{ + private static IReadOnlyList _draggedPaths = []; + + public ExplorerPaneView() + { + InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + public static readonly DependencyProperty PaneProperty = DependencyProperty.Register( + nameof(Pane), + typeof(ExplorerPaneViewModel), + typeof(ExplorerPaneView), + new PropertyMetadata(null, OnPaneChanged)); + + public ExplorerPaneViewModel? Pane + { + get => (ExplorerPaneViewModel?)GetValue(PaneProperty); + set => SetValue(PaneProperty, value); + } + + public MainViewModel Main => App.Services.Main; + + private static void OnPaneChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var view = (ExplorerPaneView)d; + if (e.OldValue is ExplorerPaneViewModel old) + { + old.PropertyChanged -= view.OnPanePropertyChanged; + old.RenameRequested -= view.OnRenameRequested; + old.ScrollIntoViewRequested -= view.OnScrollIntoViewRequested; + old.FocusSearchRequested -= view.OnFocusSearchRequested; + old.OpenInNewTabRequested -= view.OnOpenInNewTabRequested; + } + if (e.NewValue is ExplorerPaneViewModel pane) + { + pane.PropertyChanged += view.OnPanePropertyChanged; + pane.RenameRequested += view.OnRenameRequested; + pane.ScrollIntoViewRequested += view.OnScrollIntoViewRequested; + pane.FocusSearchRequested += view.OnFocusSearchRequested; + pane.OpenInNewTabRequested += view.OnOpenInNewTabRequested; + view.ApplyIconSize(); + } + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + ApplyIconSize(); + UpdateRecycleBinUi(); + ApplySidebarLayout(); + if (Pane is not null) SyncSidebarSelection(Pane.CurrentPath); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (Pane is not null) Pane.PropertyChanged -= OnPanePropertyChanged; + } + + private void OnPanePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(ExplorerPaneViewModel.IconSize): + ApplyIconSize(); + break; + case nameof(ExplorerPaneViewModel.IsRecycleBin): + UpdateRecycleBinUi(); + break; + case nameof(ExplorerPaneViewModel.CurrentPath): + if (Pane is not null) SyncSidebarSelection(Pane.CurrentPath); + break; + case nameof(ExplorerPaneViewModel.ShowSidebar): + ApplySidebarLayout(); + break; + } + } + + private void UpdateRecycleBinUi() + { + RestoreButton.Visibility = Pane is { IsRecycleBin: true } ? Visibility.Visible : Visibility.Collapsed; + HiddenToggle.IsChecked = Pane?.ShowHidden ?? false; + ExtensionToggle.IsChecked = Main.ShowFileExtensions; + } + + /// 图标/磁贴尺寸跟随视图方式(网格用 ItemsWrapGrid 的单元格尺寸,避免布局跳动)。 + private void ApplyIconSize() + { + var pane = Pane; + if (pane is null) return; + + foreach (var item in pane.Items) item.IconSize = Math.Min(pane.IconSize, 96); + + if (IconsGrid.ItemsPanelRoot is ItemsWrapGrid wrap) + { + wrap.ItemWidth = pane.TileWidth; + wrap.ItemHeight = pane.TileHeight; + } + + if (pane.IsIconsView && pane.Items.Count > 0) + { + // 视图切换后重新为可见项请求合适尺寸的缩略图 + foreach (var item in pane.Items.Take(120)) pane.Visuals.RequestThumbnail(item, pane.IconSize); + } + } + + // ── 导航窗格(侧边栏) ────────────────────────────────────────────────── + private bool _sidebarCollapsed; + + private void OnToggleSidebarClick(object sender, RoutedEventArgs e) + { + _sidebarCollapsed = !_sidebarCollapsed; + ApplySidebarLayout(); + } + + private void ApplySidebarLayout() + { + var show = Pane?.ShowSidebar != false && !_sidebarCollapsed; + SidebarHost.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + SidebarColumn.Width = show ? new GridLength(242) : new GridLength(0); + } + + private void OnSidebarItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args) + { + if (args.InvokedItem is SidebarNode node && Pane is not null) + _ = Pane.NavigateAsync(node.Location); + } + + private void OnSidebarExpanding(TreeView sender, TreeViewExpandingEventArgs args) + { + // 子项已在构建侧边栏时填充(驱动器数量少且读取廉价) + } + + /// 当前路径变化时高亮侧边栏对应节点(对齐资源管理器)。 + private void SyncSidebarSelection(string path) + { + if (string.IsNullOrEmpty(path)) return; + foreach (var node in EnumerateNodes(SidebarTree.RootNodes)) + { + if (node.Content is SidebarNode sidebarNode && sidebarNode.Contains(path)) + { + SidebarTree.SelectedNode = node; + return; + } + } + } + + private static IEnumerable EnumerateNodes(IEnumerable nodes) + { + foreach (var node in nodes) + { + yield return node; + foreach (var child in EnumerateNodes(node.Children)) yield return child; + } + } + + // ── 地址栏导航按钮 ────────────────────────────────────────────────────── + private void OnBackClick(object sender, RoutedEventArgs e) => Pane?.BackCommand.Execute(null); + + private void OnForwardClick(object sender, RoutedEventArgs e) => Pane?.ForwardCommand.Execute(null); + + private void OnUpClick(object sender, RoutedEventArgs e) => Pane?.UpCommand.Execute(null); + + private void OnRefreshClick(object sender, RoutedEventArgs e) => Pane?.RefreshCommand.Execute(null); + + // ── 命令栏 ────────────────────────────────────────────────────────────── + private void OnNewFolderClick(object sender, RoutedEventArgs e) => Pane?.NewFolderCommand.Execute(null); + + private void OnNewTextFileClick(object sender, RoutedEventArgs e) + { + if (Pane is null || Pane.Location.Kind is not (LocationKind.Folder or LocationKind.Drive)) return; + try + { + var path = Path.Combine(Pane.CurrentPath, "新建文本文档.txt"); + var i = 2; + while (File.Exists(path)) path = Path.Combine(Pane.CurrentPath, $"新建文本文档 ({i++}).txt"); + File.WriteAllText(path, string.Empty); + _ = Pane.RefreshCommand.ExecuteAsync(null); + } + catch (Exception ex) + { + Pane.ReportError($"无法新建文件:{ex.Message}"); + } + } + + private void OnNewTabClick(object sender, RoutedEventArgs e) + => Main.AddTab(NavigationLocation.FromPath(Pane?.CurrentPath ?? string.Empty)); + + private void OnCutClick(object sender, RoutedEventArgs e) => Pane?.CutSelectedCommand.Execute(SelectedItems()); + + private void OnCopyClick(object sender, RoutedEventArgs e) => Pane?.CopySelectedCommand.Execute(SelectedItems()); + + private void OnPasteClick(object sender, RoutedEventArgs e) => Pane?.PasteCommand.Execute(null); + + private void OnRenameClick(object sender, RoutedEventArgs e) + { + var item = SelectedItems().FirstOrDefault(); + if (item is not null) Pane?.StartRenameCommand.Execute(item); + } + + private void OnDeleteClick(object sender, RoutedEventArgs e) + { + var items = SelectedItems(); + if (items.Count == 0) return; + var shift = IsKeyDown(VirtualKey.Shift); + if (shift) Pane?.PermanentDeleteSelectedCommand.Execute(items); + else Pane?.DeleteSelectedCommand.Execute(items); + } + + private void OnUndoClick(object sender, RoutedEventArgs e) => Main.UndoCommand.Execute(null); + + private void OnRestoreClick(object sender, RoutedEventArgs e) => Pane?.RestoreSelectedCommand.Execute(SelectedItems()); + + private void OnSortClick(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { Tag: string tag } && Enum.TryParse(tag, out var column)) + Pane?.SortByCommand.Execute(column); + } + + private void OnSortDirectionClick(object sender, RoutedEventArgs e) + { + if (Pane is null || sender is not FrameworkElement { Tag: string tag }) return; + Pane.Sort = Pane.Sort with { Direction = tag == "Descending" ? SortDirection.Descending : SortDirection.Ascending }; + Pane.ApplySortAndRefresh(); + } + + private void OnViewModeClick(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { Tag: string tag }) Pane?.SetViewModeCommand.Execute(tag); + } + + private void OnCycleViewClick(object sender, RoutedEventArgs e) => Pane?.CycleViewModeCommand.Execute(null); + + private void OnToggleDualPaneClick(object sender, RoutedEventArgs e) => Main.ToggleDualPaneCommand.Execute(null); + + private void OnToggleHiddenClick(object sender, RoutedEventArgs e) => Pane?.ToggleHiddenCommand.Execute(null); + + private void OnToggleExtensionClick(object sender, RoutedEventArgs e) => Main.ShowFileExtensions = !Main.ShowFileExtensions; + + private async void OnShellContextMenuClick(object sender, RoutedEventArgs e) + { + var paths = SelectedItems().Select(i => i.FullPath).ToList(); + if (paths.Count == 0) + { + var dir = Pane?.CurrentPath; + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) paths.Add(dir); + } + if (paths.Count > 0) await ShowShellContextMenuAsync(paths); + } + + private async Task ShowShellContextMenuAsync(IReadOnlyList paths) + { + if (paths.Count == 0) return; + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindowInstance); + var anchor = DetailsList.TransformToVisual(null).TransformPoint(new Windows.Foundation.Point(48, 48)); + var scale = XamlRoot?.RasterizationScale ?? 1.0; + var origin = App.MainWindowInstance.AppWindow.Position; + var screen = new Windows.Graphics.PointInt32( + origin.X + (int)(anchor.X * scale), + origin.Y + (int)(anchor.Y * scale)); + + await ShellContextMenu.ShowAsync(hwnd, paths, screen); + } + + // ── 地址栏 ────────────────────────────────────────────────────────────── + private async void OnBreadcrumbClicked(BreadcrumbBar sender, BreadcrumbBarItemClickedEventArgs args) + { + if (args.Item is BreadcrumbSegment segment && Pane is not null) + await Pane.NavigateAsync(new NavigationLocation( + segment.IsDriveRoot ? LocationKind.Drive : LocationKind.Folder, + segment.Path, + segment.DisplayName, + "\uE8B7")); + } + + private void OnAddressEditorKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Enter) + { + e.Handled = true; + var text = AddressEditor.Text; + AddressEditor.Visibility = Visibility.Collapsed; + AddressBreadcrumb.Visibility = Visibility.Visible; + _ = Pane?.NavigatePathCommand.ExecuteAsync(text); + } + else if (e.Key == VirtualKey.Escape) + { + e.Handled = true; + AddressEditor.Visibility = Visibility.Collapsed; + AddressBreadcrumb.Visibility = Visibility.Visible; + } + } + + private void OnAddressEditorLostFocus(object sender, RoutedEventArgs e) + { + AddressEditor.Visibility = Visibility.Collapsed; + AddressBreadcrumb.Visibility = Visibility.Visible; + } + + /// Ctrl+L / F4:切换到可编辑的地址输入(资源管理器行为)。 + private void BeginAddressEdit() + { + AddressEditor.Text = Pane?.CurrentPath ?? string.Empty; + AddressEditor.Visibility = Visibility.Visible; + AddressBreadcrumb.Visibility = Visibility.Collapsed; + AddressEditor.Focus(FocusState.Programmatic); + AddressEditor.SelectAll(); + } + + // ── 搜索 ──────────────────────────────────────────────────────────────── + private async void OnSearchTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + // SuggestionChosen 是我们自己回填的,不重复搜索;其余变化(含程序化写入、粘贴)都要触发 + if (args.Reason == AutoSuggestionBoxTextChangeReason.SuggestionChosen) return; + if (Pane is null) return; + if (!Main.Settings.SearchAsYouType && args.Reason == AutoSuggestionBoxTextChangeReason.UserInput) + { + // 关闭"键入即搜"时只在回车/提交时搜索 + return; + } + await Pane.SearchAsync(sender.Text); + + // 顶部建议:直接把最快命中的前 8 条塞进下拉框(Everything 式即时反馈) + var suggestions = Pane.SearchResults.Take(8).Select(r => r.FullPath).ToList(); + sender.ItemsSource = suggestions; + } + + private async void OnSearchQuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + if (Pane is null) return; + await Pane.SearchAsync(args.QueryText); + } + + private async void OnSearchSuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + if (Pane is null || args.SelectedItem is not string path) return; + sender.Text = path; + await Pane.SearchAsync(path); + } + + private void OnDismissErrorClick(InfoBar sender, object args) => Pane?.DismissError(); + + // ── 列表交互 ──────────────────────────────────────────────────────────── + private List SelectedItems() + => Pane is null + ? [] + : (Pane.IsDetailsView || Pane.IsIconsView + ? (Pane.IsDetailsView ? DetailsList.SelectedItems : IconsGrid.SelectedItems).OfType().ToList() + : []); + + private void OnListSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (Pane is null) return; + var listView = sender as ListViewBase; + var selected = listView?.SelectedItems.OfType().ToList() ?? []; + Pane.OnSelectionChanged(selected); + } + + private void OnDetailsContainerChanging(ListViewBase sender, ContainerContentChangingEventArgs args) + { + if (args.InRecycleQueue || args.Item is not ExplorerItem item) return; + Pane?.Visuals.RequestIcon(item, 16); + } + + private void OnIconsContainerChanging(ListViewBase sender, ContainerContentChangingEventArgs args) + { + if (args.InRecycleQueue || args.Item is not ExplorerItem item || Pane is null) return; + item.IconSize = Math.Min(Pane.IconSize, 96); + Pane.Visuals.RequestThumbnail(item, Pane.IconSize); + } + + private void OnSearchContainerChanging(ListViewBase sender, ContainerContentChangingEventArgs args) + { + if (args.InRecycleQueue || args.Item is not SearchResultItem item) return; + Pane?.Visuals.RequestIcon(item, 16); + } + + private void OnItemDoubleTapped(object sender, DoubleTappedRoutedEventArgs e) + { + if (Pane is null) return; + var item = ResolveItemAt(e.OriginalSource as DependencyObject); + if (item is not null) _ = Pane.OpenItemAsync(item); + } + + private void OnSearchResultDoubleTapped(object sender, DoubleTappedRoutedEventArgs e) + { + var item = ResolveSearchItemAt(e.OriginalSource as DependencyObject); + if (item is not null) _ = Pane!.OpenSearchResultAsync(item); + } + + private static ExplorerItem? ResolveItemAt(DependencyObject? source) + { + while (source is not null) + { + if (source is FrameworkElement { DataContext: ExplorerItem item }) return item; + source = VisualTreeHelper.GetParent(source); + } + return null; + } + + private static SearchResultItem? ResolveSearchItemAt(DependencyObject? source) + { + while (source is not null) + { + if (source is FrameworkElement { DataContext: SearchResultItem item }) return item; + source = VisualTreeHelper.GetParent(source); + } + return null; + } + + private void OnListKeyDown(object sender, KeyRoutedEventArgs e) + { + if (Pane is null) return; + var ctrl = IsKeyDown(VirtualKey.Control); + var shift = IsKeyDown(VirtualKey.Shift); + + switch (e.Key) + { + case VirtualKey.Enter when !ctrl: + e.Handled = true; + Pane.OpenSelectedCommand.Execute(SelectedItems()); + break; + case VirtualKey.F2: + e.Handled = true; + Pane.StartRenameCommand.Execute(SelectedItems().FirstOrDefault()); + break; + case VirtualKey.Back when !ctrl: + e.Handled = true; + Pane.UpCommand.Execute(null); + break; + case VirtualKey.Delete: + e.Handled = true; + OnDeleteClick(sender, e); + break; + case VirtualKey.F5: + e.Handled = true; + Pane.RefreshCommand.Execute(null); + break; + case VirtualKey.C when ctrl && shift: + e.Handled = true; + Pane.CopyPathSelectedCommand.Execute(SelectedItems()); + break; + case VirtualKey.C when ctrl: + e.Handled = true; + Pane.CopySelectedCommand.Execute(SelectedItems()); + break; + case VirtualKey.X when ctrl: + e.Handled = true; + Pane.CutSelectedCommand.Execute(SelectedItems()); + break; + case VirtualKey.V when ctrl: + e.Handled = true; + Pane.PasteCommand.Execute(null); + break; + case VirtualKey.N when ctrl && shift: + e.Handled = true; + Pane.NewFolderCommand.Execute(null); + break; + case VirtualKey.L when ctrl: + e.Handled = true; + BeginAddressEdit(); + break; + case VirtualKey.Up when IsKeyDown(VirtualKey.Menu): + e.Handled = true; + Pane.UpCommand.Execute(null); + break; + case VirtualKey.Left when IsKeyDown(VirtualKey.Menu): + e.Handled = true; + Pane.BackCommand.Execute(null); + break; + case VirtualKey.Right when IsKeyDown(VirtualKey.Menu): + e.Handled = true; + Pane.ForwardCommand.Execute(null); + break; + } + } + + private static bool IsKeyDown(VirtualKey key) + => InputKeyboardSource.GetKeyStateForCurrentThread(key).HasFlag(CoreVirtualKeyStates.Down); + + // ── 重命名输入框 ──────────────────────────────────────────────────────── + private void OnRenameRequested(object? sender, ExplorerItem item) + { + DispatcherQueue.TryEnqueue(() => FocusRenameBox(item)); + } + + private void FocusRenameBox(ExplorerItem item) + { + var host = Pane?.IsDetailsView == true ? (DependencyObject)DetailsList : IconsGrid; + var container = host is ListViewBase list + ? list.ContainerFromItem(item) as DependencyObject + : null; + if (container is null) return; + if (FindRenameBox(container) is { } box) + { + box.Focus(FocusState.Programmatic); + box.SelectAll(); + } + } + + private static TextBox? FindRenameBox(DependencyObject root) + { + if (root is TextBox { Tag: "rename" } box) return box; + var count = VisualTreeHelper.GetChildrenCount(root); + for (var i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (FindRenameBox(child) is { } found) return found; + } + return null; + } + + private void OnRenameBoxKeyDown(object sender, KeyRoutedEventArgs e) + { + if (sender is not TextBox box || box.DataContext is not ExplorerItem item) return; + if (e.Key == VirtualKey.Enter) + { + e.Handled = true; + Pane?.CommitRenameCommand.Execute(item); + } + else if (e.Key == VirtualKey.Escape) + { + e.Handled = true; + Pane?.CancelRenameCommand.Execute(item); + } + } + + private void OnRenameBoxLostFocus(object sender, RoutedEventArgs e) + { + if (sender is TextBox { DataContext: ExplorerItem item } && item.IsRenaming) + Pane?.CommitRenameCommand.Execute(item); + } + + private void OnScrollIntoViewRequested(object? sender, ExplorerItem item) + { + if (Pane?.IsDetailsView == true) DetailsList.ScrollIntoView(item); + else IconsGrid.ScrollIntoView(item); + } + + private void OnFocusSearchRequested(object? sender, EventArgs e) + { + SearchBox.Focus(FocusState.Programmatic); + } + + private void OnOpenInNewTabRequested(object? sender, NavigationLocation location) + { + Main.AddTab(location); + } + + // ── 右键菜单(应用内 + 系统原版) ─────────────────────────────────────── + private void OnContextRequested(UIElement sender, ContextRequestedEventArgs args) + { + if (Pane is null || sender is not FrameworkElement target) return; + var selected = SelectedItems(); + var flyout = BuildContextFlyout(selected); + if (args.TryGetPosition(sender, out var position)) flyout.ShowAt(target, new FlyoutShowOptions { Position = position }); + else flyout.ShowAt(target); + args.Handled = true; + } + + private void OnSearchResultContextRequested(UIElement sender, ContextRequestedEventArgs args) + { + if (sender is not FrameworkElement target) return; + var flyout = new MenuFlyout(); + var openItem = new MenuFlyoutItem { Text = "打开" }; + openItem.Click += (_, _) => _ = Pane?.OpenSearchResultCommand.ExecuteAsync(SingleSearchResult(sender)); + var openFolder = new MenuFlyoutItem { Text = "打开所在文件夹" }; + openFolder.Click += (_, _) => _ = Pane?.OpenSearchResultFolderCommand.ExecuteAsync(SingleSearchResult(sender)); + var copyPath = new MenuFlyoutItem { Text = "复制完整路径" }; + copyPath.Click += (_, _) => + { + var item = SingleSearchResult(sender); + if (item is null) return; + var package = new DataPackage(); + package.SetText(item.FullPath); + Clipboard.SetContent(package); + }; + flyout.Items.Add(openItem); + flyout.Items.Add(openFolder); + flyout.Items.Add(copyPath); + if (args.TryGetPosition(sender, out var position)) flyout.ShowAt(target, new FlyoutShowOptions { Position = position }); + else flyout.ShowAt(target); + args.Handled = true; + + SearchResultItem? SingleSearchResult(UIElement element) + => (element as ListViewBase)?.SelectedItems.OfType().FirstOrDefault(); + } + + private MenuFlyout BuildContextFlyout(IReadOnlyList selection) + { + var flyout = new MenuFlyout(); + + if (selection.Count > 0) + { + var open = new MenuFlyoutItem { Text = "打开", Icon = new FontIcon { Glyph = "\uE8E5" } }; + open.Click += (_, _) => Pane?.OpenSelectedCommand.Execute(selection); + flyout.Items.Add(open); + + if (selection.Count == 1 && !selection[0].IsDirectory) + { + var openWith = new MenuFlyoutItem { Text = "打开方式" }; + openWith.Click += (_, _) => Pane?.OpenWithSelectedCommand.Execute(selection); + flyout.Items.Add(openWith); + } + + if (selection.Count == 1 && selection[0].IsDirectory) + { + var newTab = new MenuFlyoutItem { Text = "在新标签页中打开" }; + newTab.Click += (_, _) => Pane?.OpenInNewTabCommand.Execute(selection[0]); + flyout.Items.Add(newTab); + } + + flyout.Items.Add(new MenuFlyoutSeparator()); + + var cut = new MenuFlyoutItem { Text = "剪切", Icon = new FontIcon { Glyph = "\uE8C6" } }; + cut.Click += (_, _) => Pane?.CutSelectedCommand.Execute(selection); + var copy = new MenuFlyoutItem { Text = "复制", Icon = new FontIcon { Glyph = "\uE8C8" } }; + copy.Click += (_, _) => Pane?.CopySelectedCommand.Execute(selection); + var copyPath = new MenuFlyoutItem { Text = "复制完整路径" }; + copyPath.Click += (_, _) => Pane?.CopyPathSelectedCommand.Execute(selection); + var rename = new MenuFlyoutItem { Text = "重命名", Icon = new FontIcon { Glyph = "\uE8AC" } }; + rename.Click += (_, _) => Pane?.StartRenameCommand.Execute(selection[0]); + var delete = new MenuFlyoutItem { Text = "删除", Icon = new FontIcon { Glyph = "\uE74D" } }; + delete.Click += (_, _) => Pane?.DeleteSelectedCommand.Execute(selection); + + flyout.Items.Add(cut); + flyout.Items.Add(copy); + flyout.Items.Add(copyPath); + flyout.Items.Add(rename); + flyout.Items.Add(delete); + + if (Pane is { IsRecycleBin: true }) + { + var restore = new MenuFlyoutItem { Text = "还原" }; + restore.Click += (_, _) => Pane?.RestoreSelectedCommand.Execute(selection); + flyout.Items.Add(restore); + } + + flyout.Items.Add(new MenuFlyoutSeparator()); + + var properties = new MenuFlyoutItem { Text = "属性", Icon = new FontIcon { Glyph = "\uE946" } }; + properties.Click += (_, _) => Pane?.ShowPropertiesSelectedCommand.Execute(selection); + var reveal = new MenuFlyoutItem { Text = "在资源管理器中显示" }; + reveal.Click += (_, _) => Pane?.RevealSelectedCommand.Execute(selection); + flyout.Items.Add(properties); + flyout.Items.Add(reveal); + } + else + { + var refresh = new MenuFlyoutItem { Text = "刷新", Icon = new FontIcon { Glyph = "\uE72C" } }; + refresh.Click += (_, _) => Pane?.RefreshCommand.Execute(null); + var newFolder = new MenuFlyoutItem { Text = "新建文件夹", Icon = new FontIcon { Glyph = "\uE8B7" } }; + newFolder.Click += (_, _) => Pane?.NewFolderCommand.Execute(null); + var paste = new MenuFlyoutItem { Text = "粘贴", Icon = new FontIcon { Glyph = "\uE77F" } }; + paste.Click += (_, _) => Pane?.PasteCommand.Execute(null); + flyout.Items.Add(refresh); + flyout.Items.Add(newFolder); + flyout.Items.Add(paste); + } + + flyout.Items.Add(new MenuFlyoutSeparator()); + var showMore = new MenuFlyoutItem { Text = "显示更多选项(系统菜单)" }; + showMore.Click += async (_, _) => await ShowShellContextMenuAsync( + selection.Count > 0 + ? selection.Select(i => i.FullPath).ToList() + : (Pane?.CurrentPath is { Length: > 0 } p && Directory.Exists(p) ? [p] : [])); + flyout.Items.Add(showMore); + + return flyout; + } + + // ── 拖放 ──────────────────────────────────────────────────────────────── + private void OnDragItemsStarting(object sender, DragItemsStartingEventArgs e) + { + var items = e.Items.OfType().ToList(); + var paths = items.Select(i => i.FullPath).ToList(); + _draggedPaths = paths; + e.Data.RequestedOperation = DataPackageOperation.Copy | DataPackageOperation.Move; + + e.Data.SetDataProvider(StandardDataFormats.StorageItems, async request => + { + var deferral = request.GetDeferral(); + try + { + var storageItems = new List(); + foreach (var path in paths) + { + try + { + if (Directory.Exists(path)) + storageItems.Add(await Windows.Storage.StorageFolder.GetFolderFromPathAsync(path)); + else if (File.Exists(path)) + storageItems.Add(await Windows.Storage.StorageFile.GetFileFromPathAsync(path)); + } + catch + { + // 单个项目不可用时跳过,不阻断整次拖放 + } + } + request.SetData(storageItems); + } + finally + { + deferral.Complete(); + } + }); + } + + private void OnDragOver(object sender, DragEventArgs e) + { + if (!e.DataView.Contains(StandardDataFormats.StorageItems)) return; + var copy = IsKeyDown(VirtualKey.Control); + e.AcceptedOperation = copy ? DataPackageOperation.Copy : DataPackageOperation.Move; + e.DragUIOverride.Caption = copy ? $"复制到“{Pane?.Title}”" : $"移动到“{Pane?.Title}”"; + e.DragUIOverride.IsCaptionVisible = true; + } + + private async void OnDrop(object sender, DragEventArgs e) + { + if (Pane is null || !e.DataView.Contains(StandardDataFormats.StorageItems)) return; + var deferral = e.GetDeferral(); + try + { + var target = ResolveDropTarget(e); + var pathCopy = IsKeyDown(VirtualKey.Control); + + // 优先使用应用内拖动的路径(避免 StorageItem 往返),否则从数据包读取 + var paths = _draggedPaths.Count > 0 + ? _draggedPaths.ToList() + : (await e.DataView.GetStorageItemsAsync()).Select(i => i.Path).ToList(); + + await Pane.DropIntoAsync(paths, target, pathCopy); + } + catch (Exception ex) + { + Pane.ReportError($"拖放失败:{ex.Message}"); + } + finally + { + deferral.Complete(); + } + } + + /// 拖到文件夹行上 → 放进该文件夹;拖到空白处 → 放进当前文件夹。 + private string ResolveDropTarget(DragEventArgs e) + { + var dropPoint = e.GetPosition(DetailsList); + var elements = VisualTreeHelper.FindElementsInHostCoordinates(dropPoint, DetailsList); + foreach (var element in elements) + { + if (element is FrameworkElement { DataContext: ExplorerItem { IsDirectory: true } folder }) + return folder.FullPath; + } + return Pane?.CurrentPath ?? string.Empty; + } + + // ── 状态栏 ────────────────────────────────────────────────────────────── + private void OnIndexChipTapped(object sender, TappedRoutedEventArgs e) + { + if (Main.IndexNeedsElevation) Main.RestartElevatedCommand.Execute(null); + else Main.BuildIndexCommand.Execute(null); + } + + private void OnOpenQueueClick(object sender, RoutedEventArgs e) + { + Main.IsQueueExpanded = true; + App.MainWindowInstance.ShowOperationQueue(); + } + + private async void OnOpenSettingsClick(object sender, RoutedEventArgs e) + { + var dialog = new SettingsDialog(Main) { XamlRoot = XamlRoot }; + await dialog.ShowAsync(); + } +} diff --git a/Views/ExplorerTabView.xaml b/Views/ExplorerTabView.xaml new file mode 100644 index 0000000..5501615 --- /dev/null +++ b/Views/ExplorerTabView.xaml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/Views/ExplorerTabView.xaml.cs b/Views/ExplorerTabView.xaml.cs new file mode 100644 index 0000000..fe380f5 --- /dev/null +++ b/Views/ExplorerTabView.xaml.cs @@ -0,0 +1,98 @@ +using System.ComponentModel; +using FluidExplorer.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; + +namespace FluidExplorer.Views; + +/// 一个标签页的宿主:负责单/双窗格布局与中间分隔条。 +public sealed partial class ExplorerTabView : UserControl +{ + private bool _dragging; + private double _startX; + private double _startFirstWidth; + private double _startSecondWidth; + + public ExplorerTabView() + { + InitializeComponent(); + Loaded += (_, _) => UpdatePaneLayout(); + } + + public static readonly DependencyProperty TabViewModelProperty = DependencyProperty.Register( + nameof(TabViewModel), + typeof(ExplorerTabViewModel), + typeof(ExplorerTabView), + new PropertyMetadata(null, OnTabChanged)); + + public ExplorerTabViewModel? TabViewModel + { + get => (ExplorerTabViewModel?)GetValue(TabViewModelProperty); + set => SetValue(TabViewModelProperty, value); + } + + private static void OnTabChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var view = (ExplorerTabView)d; + if (e.OldValue is ExplorerTabViewModel old) old.PropertyChanged -= view.OnTabPropertyChanged; + if (e.NewValue is ExplorerTabViewModel tab) tab.PropertyChanged += view.OnTabPropertyChanged; + view.UpdatePaneLayout(); + } + + private void OnTabPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(ExplorerTabViewModel.IsDualPane)) UpdatePaneLayout(); + } + + private void UpdatePaneLayout() + { + var dual = TabViewModel?.IsDualPane == true; + + if (dual) + { + Splitter.Visibility = Visibility.Visible; + SecondaryPaneView.Visibility = Visibility.Visible; + SplitterColumn.Width = new GridLength(4); + if (FirstColumn.Width.IsAbsolute && SecondColumn.Width.IsAbsolute) return; + FirstColumn.Width = new GridLength(1, GridUnitType.Star); + SecondColumn.Width = new GridLength(1, GridUnitType.Star); + } + else + { + Splitter.Visibility = Visibility.Collapsed; + SecondaryPaneView.Visibility = Visibility.Collapsed; + SplitterColumn.Width = new GridLength(0); + FirstColumn.Width = new GridLength(1, GridUnitType.Star); + SecondColumn.Width = new GridLength(0); + } + } + + private void OnSplitterPointerPressed(object sender, PointerRoutedEventArgs e) + { + _dragging = true; + _startX = e.GetCurrentPoint(LayoutRoot).Position.X; + _startFirstWidth = FirstColumn.ActualWidth; + _startSecondWidth = SecondColumn.ActualWidth; + Splitter.CapturePointer(e.Pointer); + } + + private void OnSplitterPointerMoved(object sender, PointerRoutedEventArgs e) + { + if (!_dragging) return; + var delta = e.GetCurrentPoint(LayoutRoot).Position.X - _startX; + var total = _startFirstWidth + _startSecondWidth; + const double min = 240; + + var first = Math.Clamp(_startFirstWidth + delta, min, total - min); + FirstColumn.Width = new GridLength(first, GridUnitType.Pixel); + SecondColumn.Width = new GridLength(total - first, GridUnitType.Pixel); + } + + private void OnSplitterPointerReleased(object sender, PointerRoutedEventArgs e) + { + if (!_dragging) return; + _dragging = false; + Splitter.ReleasePointerCapture(e.Pointer); + } +} diff --git a/Views/SettingsDialog.xaml b/Views/SettingsDialog.xaml new file mode 100644 index 0000000..eb3f74d --- /dev/null +++ b/Views/SettingsDialog.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/ShellView.xaml.cs b/Views/ShellView.xaml.cs new file mode 100644 index 0000000..415baff --- /dev/null +++ b/Views/ShellView.xaml.cs @@ -0,0 +1,237 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using FluidExplorer.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace FluidExplorer.Views; + +/// +/// 窗口内容宿主:标签栏(位于标题栏区域)+ 文件操作队列面板。 +/// 标签容器由代码手工创建与同步,不依赖 TabView 的 ItemsSource 绑定(后者在增删标签时不稳定)。 +/// +public sealed partial class ShellView : UserControl +{ + private readonly Dictionary _containers = []; + private bool _syncingSelection; + + public ShellView() + { + InitializeComponent(); + } + + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + nameof(ViewModel), + typeof(MainViewModel), + typeof(ShellView), + new PropertyMetadata(null, OnViewModelChanged)); + + public MainViewModel? ViewModel + { + get => (MainViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + /// 标题栏拖动区(宿主窗口用 SetTitleBar 指向它)。 + public FrameworkElement DragRegion => TitleBarDragRegion; + + /// 供主题切换使用的根元素。 + public FrameworkElement ThemedRoot => RootGrid; + + public void ShowOperationQueue() + { + if (ViewModel is not null) ViewModel.IsQueueExpanded = true; + QueuePanel.Visibility = Visibility.Visible; + } + + // ── 标签页容器同步 ────────────────────────────────────────────────────── + private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var shell = (ShellView)d; + if (e.OldValue is MainViewModel old) + { + old.Tabs.CollectionChanged -= shell.OnTabsChanged; + old.PropertyChanged -= shell.OnViewModelPropertyChanged; + } + if (e.NewValue is MainViewModel fresh) + { + fresh.Tabs.CollectionChanged += shell.OnTabsChanged; + fresh.PropertyChanged += shell.OnViewModelPropertyChanged; + shell.SyncAllTabs(); + } + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(MainViewModel.SelectedTab) || ViewModel?.SelectedTab is not { } tab) return; + if (_containers.TryGetValue(tab, out var container) && !ReferenceEquals(TabHost.SelectedItem, container)) + { + _syncingSelection = true; + TabHost.SelectedItem = container; + _syncingSelection = false; + } + } + + private void OnTabsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + try + { + ApplyTabsChanged(e); + } + catch (Exception ex) + { + // 标签容器同步失败不能让异常逃进 WinRT 回调(会变成 stowed exception 结束进程) + App.Log($"标签页容器同步失败: {ex}"); + } + } + + private void ApplyTabsChanged(NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Reset) + { + SyncAllTabs(); + return; + } + + if (e.OldItems is not null) + { + foreach (ExplorerTabViewModel tab in e.OldItems) + { + if (_containers.Remove(tab, out var container)) TabHost.TabItems.Remove(container); + } + } + + if (e.NewItems is not null) + { + foreach (ExplorerTabViewModel tab in e.NewItems) AddContainer(tab); + } + + if (ViewModel?.SelectedTab is { } selected && _containers.TryGetValue(selected, out var target)) + { + _syncingSelection = true; + TabHost.SelectedItem = target; + _syncingSelection = false; + } + } + + private void SyncAllTabs() + { + TabHost.TabItems.Clear(); + _containers.Clear(); + if (ViewModel is null) return; + foreach (var tab in ViewModel.Tabs) AddContainer(tab); + if (ViewModel.SelectedTab is { } selected && _containers.TryGetValue(selected, out var target)) + { + _syncingSelection = true; + TabHost.SelectedItem = target; + _syncingSelection = false; + } + } + + private void AddContainer(ExplorerTabViewModel tab) + { + if (_containers.ContainsKey(tab)) return; + + var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center }; + var icon = new FontIcon { FontSize = 14 }; + if (Application.Current.Resources.TryGetValue("SymbolThemeFontFamily", out var family) && family is FontFamily fontFamily) + icon.FontFamily = fontFamily; + icon.SetBinding(FontIcon.GlyphProperty, new Binding + { + Path = new PropertyPath(nameof(ExplorerTabViewModel.Glyph)), + Source = tab, + Mode = BindingMode.OneWay + }); + + var text = new TextBlock { MaxWidth = 200, TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; + text.SetBinding(TextBlock.TextProperty, new Binding + { + Path = new PropertyPath(nameof(ExplorerTabViewModel.Header)), + Source = tab, + Mode = BindingMode.OneWay + }); + + header.Children.Add(icon); + header.Children.Add(text); + + var container = new TabViewItem + { + Header = header, + Content = new ExplorerTabView { TabViewModel = tab }, + DataContext = tab + }; + AutomationProperties.SetName(container, tab.Header); + + _containers[tab] = container; + TabHost.TabItems.Add(container); + } + + private void OnTabSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_syncingSelection || ViewModel is null) return; + if (TabHost.SelectedItem is TabViewItem { DataContext: ExplorerTabViewModel tab }) + ViewModel.SelectedTab = tab; + } + + private void OnAddTabButtonClick(TabView sender, object args) + { + // 延后到 TabView 自身的点击/布局处理完成后再增删标签集合,并吞掉任何异常: + // 从 DispatcherQueue 回调里逃出去的异常会变成 stowed exception 直接结束进程。 + DispatcherQueue.TryEnqueue(() => + { + try + { + ViewModel?.NewTabCommand.Execute(null); + } + catch (Exception ex) + { + App.Log($"添加标签页失败: {ex}"); + } + }); + } + + private void OnTabCloseRequested(TabView sender, TabViewTabCloseRequestedEventArgs args) + { + if (ViewModel is null) return; + if (args.Item is TabViewItem { DataContext: ExplorerTabViewModel tab }) + { + if (ViewModel.Tabs.Count <= 1) + { + App.MainWindowInstance.Close(); + return; + } + ViewModel.CloseTabCommand.Execute(tab); + return; + } + + App.MainWindowInstance.Close(); + } + + private void OnTabDroppedOutside(TabView sender, TabViewTabDroppedOutsideEventArgs args) + { + // 单窗口策略:拖出标签时仅保留在当前窗口,避免状态分散 + if (args.Tab is TabViewItem { DataContext: ExplorerTabViewModel tab } && ViewModel is not null) + ViewModel.SelectedTab = tab; + } + + private void OnClearFinishedJobsClick(object sender, RoutedEventArgs e) => ViewModel?.ClearFinishedJobsCommand.Execute(null); + + private void OnCollapseQueueClick(object sender, RoutedEventArgs e) + { + if (ViewModel is not null) ViewModel.IsQueueExpanded = false; + QueuePanel.Visibility = Visibility.Collapsed; + } + + private void OnPauseJobClick(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { Tag: JobRowViewModel row }) ViewModel?.PauseJobCommand.Execute(row); + } + + private void OnCancelJobClick(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement { Tag: JobRowViewModel row }) ViewModel?.CancelJobCommand.Execute(row); + } +} diff --git a/app.manifest b/app.manifest new file mode 100644 index 0000000..621dd62 --- /dev/null +++ b/app.manifest @@ -0,0 +1,16 @@ + + + + + + PerMonitorV2 + true + + + + + + + + + diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..455f9d4 --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + +