Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Application
|
||||||
|
x:Class="FluidExplorer.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:controls="using:Microsoft.UI.Xaml.Controls">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<controls:XamlControlsResources />
|
||||||
|
<ResourceDictionary Source="ms-appx:///Themes/Styles.xaml" />
|
||||||
|
<ResourceDictionary Source="ms-appx:///Themes/Glyphs.xaml" />
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
using FluidExplorer.Services;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace FluidExplorer;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
/// <summary>启动阶段日志:WinUI 启动期的异常在窗口出现前就终止进程,必须落盘才能定位。</summary>
|
||||||
|
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!;
|
||||||
|
|
||||||
|
/// <summary>当前主窗口(原版右键菜单、窗口图标、对话框都需要它的 HWND / XamlRoot)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows10.0.26100.0</TargetFramework>
|
||||||
|
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
|
||||||
|
<RootNamespace>FluidExplorer</RootNamespace>
|
||||||
|
<AssemblyName>FluidExplorer</AssemblyName>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<Platforms>x64;ARM64</Platforms>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<UseWinUI>true</UseWinUI>
|
||||||
|
<WindowsPackageType>None</WindowsPackageType>
|
||||||
|
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
|
<WindowsSdkPackageVersion>10.0.26100.57</WindowsSdkPackageVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
<EnableMsixTooling>false</EnableMsixTooling>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<GenerateAppInstallerFile>false</GenerateAppInstallerFile>
|
||||||
|
<AppxPackage>false</AppxPackage>
|
||||||
|
<!-- MVVM Toolkit 的 AOT 建议与本项目无关(不发布 AOT),只保留真正有意义的告警 -->
|
||||||
|
<NoWarn>$(NoWarn);MVVMTK0045</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||||
|
<!-- Microsoft 官方 MVVM 工具包(仅用于 INotifyPropertyChanged 样板代码,不含任何 UI 组件) -->
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Data;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Helpers;
|
||||||
|
|
||||||
|
/// <summary>bool → Visibility(ConverterParameter="invert" 可反转)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>字符串非空 → Visible。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>数量 > 0 → Visible。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>bool 取反。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>回收站/图库等视图才显示的操作按钮。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>作业状态 → 中文文案(状态栏/队列面板用)。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>作业 → 进度明细文案:条目数 / 体积 / 速度 / 剩余时间。</summary>
|
||||||
|
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<string> { $"{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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>作业 → 暂停/继续按钮图标。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Helpers;
|
||||||
|
|
||||||
|
/// <summary>DispatcherQueue 的 await 化封装:把 UI 线程更新变成可等待的操作,便于批量提交与顺序保证。</summary>
|
||||||
|
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<T> EnqueueAsync<T>(this DispatcherQueue queue, Func<T> func)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
if (!queue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(func());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
tcs.TrySetException(new InvalidOperationException("DispatcherQueue 已关闭,无法调度到 UI 线程。"));
|
||||||
|
}
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>即发即忘的 UI 调度(不需要等待结果的场合)。</summary>
|
||||||
|
public static void Post(this DispatcherQueue queue, Action action) => queue.TryEnqueue(() => action());
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Window
|
||||||
|
x:Class="FluidExplorer.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:views="using:FluidExplorer.Views"
|
||||||
|
Title="文件资源管理器">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Window 里刻意不放任何 x:Bind:WinUI 为带 Converter 的 x:Bind 生成代码时要求绑定根是
|
||||||
|
FrameworkElement,而 Window 不是。所有界面内容都在 ShellView 里。
|
||||||
|
-->
|
||||||
|
<views:ShellView x:Name="Shell" />
|
||||||
|
</Window>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把标签栏右侧的留白让给系统标题栏按钮,保证标签不被按钮压住。</summary>
|
||||||
|
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 信息,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>标题栏按钮颜色跟随当前主题。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>主题切换(用户手动指定时覆盖系统跟随)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>文件冲突对话框:不弹系统模态框,全部由应用自己处理。</summary>
|
||||||
|
private async Task<ConflictResolution> 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
|
||||||
|
{
|
||||||
|
// 关闭时保存失败不应阻塞退出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Models;
|
||||||
|
|
||||||
|
/// <summary>轻量文件系统条目:由快速枚举一次性填充(不产生额外系统调用)。</summary>
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// <summary>Windows 资源管理器风格的尺寸文本(已按 1024 进制并为文件夹留空)。</summary>
|
||||||
|
public string SizeText => IsDirectory ? string.Empty : FormatSize(Size);
|
||||||
|
|
||||||
|
/// <summary>类型描述:优先使用注册表里的友好类型名(惰性、带缓存)。</summary>
|
||||||
|
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]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>列表里的一行:可观察包装,缩略图/图标异步补齐,不阻塞滚动。</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>界面显示名("显示文件扩展名"关闭时隐藏扩展名,与资源管理器一致)。</summary>
|
||||||
|
public string DisplayName
|
||||||
|
{
|
||||||
|
get => _displayName ?? Entry.Name;
|
||||||
|
set { if (_displayName != value) { _displayName = value; OnPropertyChanged(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>是否为新建/重命名中的占位项(这些项要滚动到可见并进入编辑状态)。</summary>
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// <summary>图标或缩略图(ImageSource 由 UI 线程创建后写入;先给几何图标占位,避免跳动)。</summary>
|
||||||
|
public ImageSource? Icon
|
||||||
|
{
|
||||||
|
get => _icon;
|
||||||
|
set { if (!ReferenceEquals(_icon, value)) { _icon = value; OnPropertyChanged(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>是否已经替换为真实缩略图(用于淡入动画)。</summary>
|
||||||
|
public bool HasThumbnail { get; set; }
|
||||||
|
|
||||||
|
public bool IsSelected
|
||||||
|
{
|
||||||
|
get => _isSelected;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_isSelected == value) return;
|
||||||
|
_isSelected = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(ShowCheckBox));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>复选框是否可见(对齐资源管理器:选中时显示,或用户开启了"始终显示项目复选框")。</summary>
|
||||||
|
public bool ShowCheckBox => _isSelected || AlwaysShowCheckBoxes;
|
||||||
|
|
||||||
|
/// <summary>全局设置:始终显示项目复选框。</summary>
|
||||||
|
public static bool AlwaysShowCheckBoxes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>当前视图所需的图标像素尺寸(由窗格在切换视图方式时写入)。</summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>当前文件夹的内容集合。</summary>
|
||||||
|
public sealed class FolderListing
|
||||||
|
{
|
||||||
|
public required string Path { get; set; }
|
||||||
|
public ObservableCollection<ExplorerItem> Items { get; } = [];
|
||||||
|
public bool IsLoading { get; set; }
|
||||||
|
public bool IsComplete { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public int TotalCount => Items.Count;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>视图模式:对齐资源管理器(详情/列表/网格三档/内容)。</summary>
|
||||||
|
public enum ViewMode
|
||||||
|
{
|
||||||
|
ExtraLargeIcons,
|
||||||
|
LargeIcons,
|
||||||
|
MediumIcons,
|
||||||
|
SmallIcons,
|
||||||
|
List,
|
||||||
|
Details,
|
||||||
|
Tiles,
|
||||||
|
Content
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum GroupBy
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Name,
|
||||||
|
DateModified,
|
||||||
|
Type,
|
||||||
|
Size
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
namespace FluidExplorer.Navigation;
|
||||||
|
|
||||||
|
public enum LocationKind
|
||||||
|
{
|
||||||
|
Home,
|
||||||
|
Gallery,
|
||||||
|
QuickAccess,
|
||||||
|
ThisPc,
|
||||||
|
RecycleBin,
|
||||||
|
Network,
|
||||||
|
Drive,
|
||||||
|
Folder,
|
||||||
|
Search
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>一个可导航的位置(侧边栏节点、面包屑、标签页都基于它)。</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>是否是外壳虚拟位置(不能用 System.IO 直接枚举,需要走 shell: 视图)。</summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>前进/后退历史(资源管理器行为:新导航截断前进栈)。</summary>
|
||||||
|
public sealed class NavigationHistory
|
||||||
|
{
|
||||||
|
private readonly List<NavigationLocation> _back = [];
|
||||||
|
private readonly List<NavigationLocation> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` 里 `<clear/>`):所有依赖已在全局包缓存中,可直接还原。
|
||||||
|
依赖仅 `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
|
||||||
|
```
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using FluidExplorer.Services.FileSystem;
|
||||||
|
using FluidExplorer.Services.Icons;
|
||||||
|
using FluidExplorer.Services.Operations;
|
||||||
|
using FluidExplorer.Services.Search;
|
||||||
|
using FluidExplorer.ViewModels;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services;
|
||||||
|
|
||||||
|
/// <summary>组合根:所有服务在这里创建、装配,并交给视图模型。</summary>
|
||||||
|
public sealed class AppServices
|
||||||
|
{
|
||||||
|
public AppServices(DispatcherQueue ui)
|
||||||
|
{
|
||||||
|
Ui = ui;
|
||||||
|
Settings = AppSettings.Load();
|
||||||
|
FileSystem = new FastFileSystemService();
|
||||||
|
Search = new SearchService(FileSystem);
|
||||||
|
Icons = CreateIconService(ui);
|
||||||
|
Operations = CreateOperationService();
|
||||||
|
Main = new MainViewModel(FileSystem, Search, Operations, Icons, Settings, ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DispatcherQueue Ui { get; }
|
||||||
|
public AppSettings Settings { get; }
|
||||||
|
public IFileSystemService FileSystem { get; }
|
||||||
|
public SearchService Search { get; }
|
||||||
|
public IIconService Icons { get; }
|
||||||
|
public IFileOperationService Operations { get; }
|
||||||
|
public MainViewModel Main { get; }
|
||||||
|
|
||||||
|
/// <summary>接入 Windows 外壳原版图标(imageres.dll / IShellItemImageFactory)。</summary>
|
||||||
|
private static IIconService CreateIconService(DispatcherQueue ui)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new ShellIconService(ui);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new PlaceholderIconService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>接入文件操作队列引擎。</summary>
|
||||||
|
private static IFileOperationService CreateOperationService()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new FileOperationService();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new PlaceholderOperationService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>接入 NTFS USN/MFT 索引(Everything 式极速搜索)。</summary>
|
||||||
|
public void WireSearchIndex()
|
||||||
|
{
|
||||||
|
Search.IndexFactory = volumeRoot =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new FluidExplorer.Services.Search.Usn.UsnVolumeIndex(volumeRoot);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 非 NTFS 卷等异常情况:该卷不建索引,搜索自动退化为实时扫描
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FluidExplorer.Models;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services;
|
||||||
|
|
||||||
|
/// <summary>外观模式:默认跟随系统(深浅色自动切换)。</summary>
|
||||||
|
public enum AppThemeMode
|
||||||
|
{
|
||||||
|
System,
|
||||||
|
Light,
|
||||||
|
Dark
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SearchScope
|
||||||
|
{
|
||||||
|
/// <summary>全盘(走 NTFS 索引,Everything 式,毫秒级)。</summary>
|
||||||
|
Global,
|
||||||
|
/// <summary>仅当前文件夹及其子目录。</summary>
|
||||||
|
CurrentFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FolderViewState
|
||||||
|
{
|
||||||
|
public ViewMode ViewMode { get; set; } = ViewMode.Details;
|
||||||
|
public SortColumn SortColumn { get; set; } = SortColumn.Name;
|
||||||
|
public SortDirection SortDirection { get; set; } = SortDirection.Ascending;
|
||||||
|
public GroupBy GroupBy { get; set; } = GroupBy.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>用户设置(%LOCALAPPDATA%\FluidExplorer\settings.json),失败时退回程序目录。</summary>
|
||||||
|
public sealed class AppSettings
|
||||||
|
{
|
||||||
|
public AppThemeMode ThemeMode { get; set; } = AppThemeMode.System;
|
||||||
|
|
||||||
|
// 视图
|
||||||
|
public bool ShowHiddenFiles { get; set; }
|
||||||
|
public bool ShowSystemFiles { get; set; }
|
||||||
|
public bool ShowFileExtensions { get; set; } = true;
|
||||||
|
public bool AlwaysShowCheckBoxes { get; set; }
|
||||||
|
public bool ShowStatusBar { get; set; } = true;
|
||||||
|
|
||||||
|
// 浏览
|
||||||
|
public bool OpenFoldersInNewTab { get; set; }
|
||||||
|
public bool DoubleClickToOpen { get; set; } = true;
|
||||||
|
public bool RestoreTabsOnStartup { get; set; } = true;
|
||||||
|
public string DefaultStartPath { get; set; } = "";
|
||||||
|
public List<string> PinnedFolders { get; set; } = [];
|
||||||
|
public List<string> RecentFolders { get; set; } = [];
|
||||||
|
public List<string> OpenTabs { get; set; } = [];
|
||||||
|
public bool DualPane { get; set; }
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
public SearchScope SearchScope { get; set; } = SearchScope.Global;
|
||||||
|
public List<string> IndexedVolumes { get; set; } = [];
|
||||||
|
public bool IndexOnStartup { get; set; } = true;
|
||||||
|
public bool SearchAsYouType { get; set; } = true;
|
||||||
|
|
||||||
|
// 文件操作
|
||||||
|
public bool DeleteToRecycleBin { get; set; } = true;
|
||||||
|
public ConflictPolicySetting DefaultConflictPolicy { get; set; } = ConflictPolicySetting.Ask;
|
||||||
|
public bool ConfirmPermanentDelete { get; set; } = true;
|
||||||
|
public bool KeepWindowOpenDuringOperations { get; set; } = true;
|
||||||
|
|
||||||
|
// 动效(克制:只保留系统原生的必要动画)
|
||||||
|
public bool AnimationsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
// 窗口
|
||||||
|
public double WindowLeft { get; set; } = double.NaN;
|
||||||
|
public double WindowTop { get; set; } = double.NaN;
|
||||||
|
public double WindowWidth { get; set; } = 1280;
|
||||||
|
public double WindowHeight { get; set; } = 800;
|
||||||
|
public bool WindowMaximized { get; set; }
|
||||||
|
|
||||||
|
/// <summary>按文件夹记住视图方式(对齐资源管理器的"按文件夹记住视图设置")。</summary>
|
||||||
|
public Dictionary<string, FolderViewState> FolderViews { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string SettingsFilePath { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
public static AppSettings Load()
|
||||||
|
{
|
||||||
|
foreach (var candidate in CandidatePaths())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(candidate)) continue;
|
||||||
|
var json = File.ReadAllText(candidate);
|
||||||
|
var loaded = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions);
|
||||||
|
if (loaded is null) continue;
|
||||||
|
loaded.SettingsFilePath = candidate;
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 设置文件损坏时忽略,使用默认值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = new AppSettings();
|
||||||
|
settings.SettingsFilePath = CandidatePaths().First();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
foreach (var target in CandidatePaths())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(target);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
SettingsFilePath = target;
|
||||||
|
File.WriteAllText(target, JsonSerializer.Serialize(this, JsonOptions));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 换下一个候选位置(例如沙箱/只读环境)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public FolderViewState GetFolderView(string path)
|
||||||
|
{
|
||||||
|
if (FolderViews.TryGetValue(path, out var state)) return state;
|
||||||
|
state = new FolderViewState();
|
||||||
|
FolderViews[path] = state;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> CandidatePaths()
|
||||||
|
{
|
||||||
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (!string.IsNullOrEmpty(localAppData))
|
||||||
|
yield return Path.Combine(localAppData, "FluidExplorer", "settings.json");
|
||||||
|
yield return Path.Combine(AppContext.BaseDirectory, "settings.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ConflictPolicySetting
|
||||||
|
{
|
||||||
|
Ask,
|
||||||
|
Replace,
|
||||||
|
Skip,
|
||||||
|
KeepBoth
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO.Enumeration;
|
||||||
|
using FluidExplorer.Models;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.FileSystem;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 资源管理器最核心的性能路径:目录枚举。
|
||||||
|
/// 使用 .NET 的 FileSystemEnumerable(底层为 NtQueryDirectoryFile + 大缓冲批量返回),
|
||||||
|
/// 一次调用即可拿到名称/属性/大小/时间,比 DirectoryInfo 逐文件查询快一个数量级。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FastFileSystemService : IFileSystemService
|
||||||
|
{
|
||||||
|
private static readonly EnumerationOptions Options = new()
|
||||||
|
{
|
||||||
|
RecurseSubdirectories = false,
|
||||||
|
IgnoreInaccessible = true,
|
||||||
|
AttributesToSkip = 0, // 隐藏/系统文件也枚举出来,由界面决定是否显示
|
||||||
|
ReturnSpecialDirectories = false,
|
||||||
|
MatchType = MatchType.Simple,
|
||||||
|
BufferSize = 0 // 0 = 使用平台默认的大缓冲区
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool DirectoryExists(string path)
|
||||||
|
{
|
||||||
|
try { return Directory.Exists(path); }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool FileExists(string path)
|
||||||
|
{
|
||||||
|
try { return File.Exists(path); }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分批异步枚举:首批(batchSize 条)会尽快回调,让界面立刻有内容;
|
||||||
|
/// 整个枚举可取消,不会阻塞调用线程。
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnumerateAsync(
|
||||||
|
string path,
|
||||||
|
FolderListing listing,
|
||||||
|
Func<IReadOnlyList<FileEntry>, Task> onBatch,
|
||||||
|
int batchSize = 256,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
listing.Path = path;
|
||||||
|
listing.IsLoading = true;
|
||||||
|
listing.IsComplete = false;
|
||||||
|
listing.Error = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var buffer = new List<FileEntry>(batchSize);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
foreach (var entry in EnumerateCore(path, cancellationToken))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
buffer.Add(entry);
|
||||||
|
if (buffer.Count >= batchSize)
|
||||||
|
{
|
||||||
|
await onBatch(buffer).ConfigureAwait(false);
|
||||||
|
buffer = new List<FileEntry>(batchSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.Count > 0) await onBatch(buffer).ConfigureAwait(false);
|
||||||
|
_ = sw.Elapsed;
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
listing.IsComplete = true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
listing.IsComplete = false;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
listing.Error = $"没有访问权限:{ex.Message}";
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
listing.Error = "文件夹不存在或已被移动。";
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
// 网络路径断开、设备未就绪等:给出可读提示而不是抛出
|
||||||
|
listing.Error = $"无法读取此位置:{ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
listing.IsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<FileEntry> Enumerate(string path, bool includeHidden = true)
|
||||||
|
{
|
||||||
|
var list = new List<FileEntry>(256);
|
||||||
|
foreach (var e in EnumerateCore(path, CancellationToken.None))
|
||||||
|
{
|
||||||
|
if (!includeHidden && e.IsHidden) continue;
|
||||||
|
list.Add(e);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>核心枚举:单次系统调用序列,无逐文件 stat。</summary>
|
||||||
|
private static IEnumerable<FileEntry> EnumerateCore(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalized = PathHelper.NormalizeForApi(path);
|
||||||
|
var enumerable = new FileSystemEnumerable<FileEntry>(
|
||||||
|
normalized,
|
||||||
|
static (ref FileSystemEntry entry) => new FileEntry
|
||||||
|
{
|
||||||
|
Name = entry.FileName.ToString(),
|
||||||
|
FullPath = entry.ToFullPath(),
|
||||||
|
IsDirectory = entry.IsDirectory,
|
||||||
|
Size = entry.IsDirectory ? 0 : entry.Length,
|
||||||
|
ModifiedUtc = entry.LastWriteTimeUtc.UtcDateTime,
|
||||||
|
CreatedUtc = entry.CreationTimeUtc.UtcDateTime,
|
||||||
|
AccessedUtc = entry.LastAccessTimeUtc.UtcDateTime,
|
||||||
|
Attributes = entry.Attributes
|
||||||
|
},
|
||||||
|
Options);
|
||||||
|
|
||||||
|
foreach (var item in enumerable)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>递归求文件夹大小:并行遍历 + 可取消,用于属性对话框与状态栏提示。</summary>
|
||||||
|
public Task<long> GetDirectorySizeAsync(string path, CancellationToken cancellationToken)
|
||||||
|
=> Task.Run(() =>
|
||||||
|
{
|
||||||
|
long total = 0;
|
||||||
|
var stack = new Stack<string>();
|
||||||
|
stack.Push(path);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var dir = stack.Pop();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Directory.EnumerateFiles(dir))
|
||||||
|
{
|
||||||
|
try { total += new FileInfo(file).Length; } catch { /* 跳过无法访问的项 */ }
|
||||||
|
}
|
||||||
|
foreach (var sub in Directory.EnumerateDirectories(dir))
|
||||||
|
{
|
||||||
|
var info = new DirectoryInfo(sub);
|
||||||
|
if ((info.Attributes & FileAttributes.ReparsePoint) != 0) continue; // 不跟随链接,防止环
|
||||||
|
stack.Push(sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 跳过无权限目录 */ }
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>路径规范化统一入口(长路径、去掉尾部分隔符、避免重复分隔符)。</summary>
|
||||||
|
public static class PathHelper
|
||||||
|
{
|
||||||
|
public const string ExtendedPrefix = @"\\?\";
|
||||||
|
public const string ExtendedUncPrefix = @"\\?\UNC\";
|
||||||
|
|
||||||
|
/// <summary>用于 Win32/文件系统 API 的路径(超长路径自动加 \\?\ 前缀)。</summary>
|
||||||
|
public static string NormalizeForApi(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return path;
|
||||||
|
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal)) return path;
|
||||||
|
path = path.Replace('/', '\\');
|
||||||
|
// 去掉重复分隔符(保留 UNC 开头的两个)
|
||||||
|
while (path.Contains(@"\\") && !path.StartsWith(@"\\", StringComparison.Ordinal)) path = path.Replace(@"\\", @"\");
|
||||||
|
if (path.Length >= 248)
|
||||||
|
{
|
||||||
|
return path.StartsWith(@"\\", StringComparison.Ordinal)
|
||||||
|
? ExtendedUncPrefix + path[2..]
|
||||||
|
: ExtendedPrefix + path;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>去掉 \\?\ 前缀,用于显示。</summary>
|
||||||
|
public static string StripExtendedPrefix(string path)
|
||||||
|
{
|
||||||
|
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal)) return @"\\" + path[ExtendedUncPrefix.Length..];
|
||||||
|
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal)) return path[ExtendedPrefix.Length..];
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>规范化显示路径:统一分隔符、去掉末尾分隔符(根目录除外)。</summary>
|
||||||
|
public static string NormalizeDisplay(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return path;
|
||||||
|
var p = StripExtendedPrefix(path).Replace('/', '\\');
|
||||||
|
if (p.Length > 3 && p.EndsWith('\\')) p = p.TrimEnd('\\');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetParent(string path)
|
||||||
|
{
|
||||||
|
var p = NormalizeDisplay(path);
|
||||||
|
if (p.Length <= 3) return p; // "C:\" 的父级还是自己
|
||||||
|
var idx = p.LastIndexOf('\\');
|
||||||
|
if (idx < 0) return p;
|
||||||
|
var parent = p[..idx];
|
||||||
|
if (parent.Length == 2 && parent[1] == ':') parent += "\\";
|
||||||
|
return parent.Length == 0 ? p : parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetName(string path)
|
||||||
|
{
|
||||||
|
var p = NormalizeDisplay(path);
|
||||||
|
if (p.Length <= 3) return p;
|
||||||
|
var idx = p.LastIndexOf('\\');
|
||||||
|
return idx < 0 ? p : p[(idx + 1)..];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using FluidExplorer.Models;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.FileSystem;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 快速目录枚举:内部使用 .NET 的 FileSystemEnumerable(NtQueryDirectoryFile 批量缓冲)
|
||||||
|
/// 一次取回名称/属性/大小/时间,避免逐文件 Win32 调用。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFileSystemService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 异步枚举目录,分批回调(首批尽可能快,保证 UI 立刻有内容),
|
||||||
|
/// 整体可取消;无权限/网络超时不抛异常,通过 listing.Error 汇报。
|
||||||
|
/// </summary>
|
||||||
|
Task EnumerateAsync(
|
||||||
|
string path,
|
||||||
|
FolderListing listing,
|
||||||
|
Func<IReadOnlyList<FileEntry>, Task> onBatch,
|
||||||
|
int batchSize = 256,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>同步枚举(供后台索引/搜索回退用),返回全部条目。</summary>
|
||||||
|
IReadOnlyList<FileEntry> Enumerate(string path, bool includeHidden = true);
|
||||||
|
|
||||||
|
bool DirectoryExists(string path);
|
||||||
|
bool FileExists(string path);
|
||||||
|
|
||||||
|
/// <summary>计算文件夹大小(后台、可取消)。</summary>
|
||||||
|
Task<long> GetDirectorySizeAsync(string path, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Icons;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标/缩略图服务:全部来自 Windows 外壳(imageres.dll 等系统图标库、IShellItemImageFactory),
|
||||||
|
/// 也就是资源管理器本身显示的那批原版图标,不做自制图标。
|
||||||
|
/// </summary>
|
||||||
|
public interface IIconService
|
||||||
|
{
|
||||||
|
/// <summary>系统小图标(16/32/48px 的多尺寸 HICON),用于列表与侧边栏。目录、驱动器、特殊文件夹同样走外壳。</summary>
|
||||||
|
Task<ImageSource?> GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>大缩略图(SIIGBF_THUMBNAILONLY + 图标回退),用于网格/磁贴视图。失败返回 null。</summary>
|
||||||
|
Task<ImageSource?> GetThumbnailAsync(string path, int size, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>按扩展名取通用图标(同类型文件共用一个位图,命中率极高)。</summary>
|
||||||
|
Task<ImageSource?> GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>取某个已知外壳文件夹(如 "shell:RecycleBinFolder"、"::{20D04FE0-3AEA-1069-A2D8-08002B30309D}")的图标。</summary>
|
||||||
|
ImageSource? GetSpecialFolderIcon(string parsingName, int size);
|
||||||
|
|
||||||
|
void ClearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>图标服务需要在 UI 线程创建 ImageSource,构造时注入 DispatcherQueue。</summary>
|
||||||
|
public interface IIconServiceHost
|
||||||
|
{
|
||||||
|
DispatcherQueue DispatcherQueue { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,894 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
|
using Microsoft.UI.Xaml.Media.Imaging;
|
||||||
|
using Windows.Graphics.Imaging;
|
||||||
|
using Windows.Storage.Streams;
|
||||||
|
using BgraBuffer = FluidExplorer.Services.Icons.ShellNative.BgraBuffer;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Icons;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 系统原版图标 / 缩略图服务。
|
||||||
|
///
|
||||||
|
/// 设计要点:
|
||||||
|
/// 1) 取图全部在外壳与 GDI 侧完成(可后台线程),只有 <see cref="SoftwareBitmapSource"/> 的创建被派回 UI 线程;
|
||||||
|
/// SoftwareBitmapSource 是 UI 线程亲和对象,后台线程碰它会直接崩。
|
||||||
|
/// 2) 结果带 LRU 缓存 + 同键请求合并(Lazy<Task>),列表快速滚动时不会重复解码同一张图。
|
||||||
|
/// 3) 任何未预期异常都会被吞成 null 并写 Debug 输出,服务层绝不把异常抛进 UI 线程。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShellIconService : IIconService
|
||||||
|
{
|
||||||
|
private const int IconCacheCapacity = 512;
|
||||||
|
private const int ThumbnailCacheCapacity = 256;
|
||||||
|
private const int MaxConcurrentNativeWork = 4;
|
||||||
|
|
||||||
|
/// <summary>缩略图 E_PENDING 重试参数。</summary>
|
||||||
|
private const int ThumbnailRetryCount = 3;
|
||||||
|
|
||||||
|
/// <summary>缩略图 E_PENDING 重试间隔(毫秒)。</summary>
|
||||||
|
private const int ThumbnailRetryDelayMs = 200;
|
||||||
|
|
||||||
|
/// <summary>UI 调度器;极端情况下可能为 null(此时退化为在调用线程创建 ImageSource)。</summary>
|
||||||
|
private readonly DispatcherQueue? _uiDispatcher;
|
||||||
|
private readonly Func<double> _scaleProvider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 图标缓存:值类型是 <see cref="Task{TResult}"/>。
|
||||||
|
/// 缓存"共享的 Task"而不是最终 ImageSource,才能在解码未完成时就完成同键请求合并;
|
||||||
|
/// 这也是 Lazy<Task> 模式的落点(LruCache 内层用 Lazy 保证工厂只跑一次)。
|
||||||
|
/// </summary>
|
||||||
|
private readonly LruCache<Task<ImageSource?>> _iconCache = new(IconCacheCapacity);
|
||||||
|
|
||||||
|
/// <summary>缩略图缓存,容量更小(缩略图位图大得多,全部是 256px 级别的位图)。</summary>
|
||||||
|
private readonly LruCache<Task<ImageSource?>> _thumbnailCache = new(ThumbnailCacheCapacity);
|
||||||
|
|
||||||
|
/// <summary>把纯 GDI 取图/解码节流在 4 路,避免整屏滚动时把 CPU 打满。</summary>
|
||||||
|
private readonly SemaphoreSlim _nativeThrottle = new(MaxConcurrentNativeWork, MaxConcurrentNativeWork);
|
||||||
|
|
||||||
|
/// <summary>默认的 DPI 缩放(rasterizationScale 为 null 时按 1.0 处理)。</summary>
|
||||||
|
private static readonly Func<double> DefaultScale = static () => 1.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 构造。签名固定为 (DispatcherQueue, Func<double>?),见 Services/AppServices.cs 的调用。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 构造函数**不抛异常**:uiDispatcher 为 null 时回退到当前线程的 DispatcherQueue,
|
||||||
|
/// 再拿不到就退化为"直接在调用线程创建 ImageSource"。
|
||||||
|
/// 宁可降级也不要让 App 启动阶段直接崩掉。
|
||||||
|
/// </remarks>
|
||||||
|
public ShellIconService(DispatcherQueue uiDispatcher, Func<double>? rasterizationScale = null)
|
||||||
|
{
|
||||||
|
_uiDispatcher = uiDispatcher ?? TryGetCurrentDispatcher();
|
||||||
|
_scaleProvider = rasterizationScale ?? DefaultScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>尽力拿到当前线程的 DispatcherQueue;拿不到返回 null(构造期不抛异常)。</summary>
|
||||||
|
private static DispatcherQueue? TryGetCurrentDispatcher()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return DispatcherQueue.GetForCurrentThread();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] GetForCurrentThread 失败: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>当前构造时注入的 UI 调度器(ViewModel 可用它确认自己在哪个线程上调用)。</summary>
|
||||||
|
public DispatcherQueue? UiDispatcher => _uiDispatcher;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// IIconService
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ImageSource?> GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) return Task.FromResult<ImageSource?>(null);
|
||||||
|
|
||||||
|
int logical = NormalizeSize(size);
|
||||||
|
|
||||||
|
// 缓存键规则(关键,不要随意改动):
|
||||||
|
// - 文件 / 目录 / 驱动器一律用完整路径。因为 .exe 会带自身嵌入图标,
|
||||||
|
// .lnk 指向不同目标,文件夹可能带 OneDrive / 共享 / 快捷方式角标,
|
||||||
|
// 只有按路径缓存才不会串图。
|
||||||
|
// - 扩展名走 ".ext" 键(见 GetExtensionIconAsync),SHGFI_USEFILEATTRIBUTES 不碰磁盘。
|
||||||
|
string key = BuildIconKey(path, isDirectory, logical);
|
||||||
|
|
||||||
|
return GetOrAddAsync(_iconCache, key, ct => LoadIconCoreAsync(path, isDirectory, logical, ct), cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 公开方法绝不抛异常给调用方(列表滚动会崩主程序)
|
||||||
|
Debug.WriteLine($"[ShellIconService] GetIconAsync 失败 {path}: {ex}");
|
||||||
|
return Task.FromResult<ImageSource?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ImageSource?> GetThumbnailAsync(string path, int size, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) return Task.FromResult<ImageSource?>(null);
|
||||||
|
|
||||||
|
int logical = NormalizeSize(size);
|
||||||
|
string key = "thumb|" + logical.ToString() + "|" + path;
|
||||||
|
|
||||||
|
return GetOrAddAsync(_thumbnailCache, key, ct => LoadThumbnailCoreAsync(path, logical, ct), cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] GetThumbnailAsync 失败 {path}: {ex}");
|
||||||
|
return Task.FromResult<ImageSource?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ImageSource?> GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string ext = NormalizeExtension(extension);
|
||||||
|
if (ext.Length == 0) return Task.FromResult<ImageSource?>(null);
|
||||||
|
|
||||||
|
int logical = NormalizeSize(size);
|
||||||
|
|
||||||
|
// ".ext" 键:同类型文件共用一个位图,命中率极高,且不需要访问磁盘
|
||||||
|
string key = "ext|" + logical.ToString() + "|." + ext;
|
||||||
|
|
||||||
|
return GetOrAddAsync(_iconCache, key, ct => LoadExtensionIconCoreAsync(ext, logical, ct), cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] GetExtensionIconAsync 失败 {extension}: {ex}");
|
||||||
|
return Task.FromResult<ImageSource?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// 注意:本方法是同步签名(见 IIconService)。它只应在 UI 线程调用,内部会同步等待取图完成,
|
||||||
|
/// 因此仅适合启动阶段取少量侧边栏图标,不要在滚动/渲染路径里按行调用。
|
||||||
|
/// </remarks>
|
||||||
|
public ImageSource? GetSpecialFolderIcon(string parsingName, int size)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(parsingName)) return null;
|
||||||
|
|
||||||
|
// 该重载不会死锁:内部从不依赖调用方线程继续泵消息(软件位图那一段走 TryEnqueue)
|
||||||
|
return GetIconAsync(parsingName, isDirectory: true, size, CancellationToken.None)
|
||||||
|
.WaitAsync(TimeSpan.FromSeconds(5))
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
}
|
||||||
|
catch (TimeoutException ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] 特殊文件夹图标超时 {parsingName}: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 含 OperationCanceledException:公开方法一律不外抛
|
||||||
|
Debug.WriteLine($"[ShellIconService] 特殊文件夹图标失败 {parsingName}: {ex}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ClearCache()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_iconCache.Clear();
|
||||||
|
_thumbnailCache.Clear();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] ClearCache 失败: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 取图主流程(全部在后台线程执行)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>文件 / 目录 / 驱动器 / 已知外壳对象的图标。</summary>
|
||||||
|
private Task<ImageSource?> LoadIconCoreAsync(string path, bool isDirectory, int logicalSize, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return RunSafeAsync(async () =>
|
||||||
|
{
|
||||||
|
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BgraBuffer? pixels = null;
|
||||||
|
|
||||||
|
if (IsSpecialParsingName(path))
|
||||||
|
{
|
||||||
|
// shell:RecycleBinFolder、::{20D04FE0-...}(此电脑)之类:
|
||||||
|
// 必须先解析成 PIDL,再交给 SHGetFileInfoW 的 PIDL 重载
|
||||||
|
pixels = ShellParsingName.IsShellPrefix(path)
|
||||||
|
? LoadIconFromShellParsingName(path, logicalSize)
|
||||||
|
: LoadIconFromParsingName(path, logicalSize);
|
||||||
|
}
|
||||||
|
else if (!isDirectory && logicalSize >= 48 && File.Exists(path))
|
||||||
|
{
|
||||||
|
// 大尺寸文件图标优先走 IShellItemImageFactory + SIIGBF_ICONONLY:
|
||||||
|
// 它返回的是外壳为该文件类型选定的高清图标(含 .exe/.lnk 的个性化图标),
|
||||||
|
// 比把 32x32 拉伸到 48 清晰得多。
|
||||||
|
// File.Exists 先挡一道:SHCreateItemFromParsingName 对不存在的路径会抛/失败,避免无谓开销。
|
||||||
|
pixels = LoadIconViaShellItemImageFactory(path, logicalSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退 / 小尺寸路径:SHGetFileInfoW。
|
||||||
|
// 小尺寸用这条更贴近资源管理器的列表视图(16/32 就是系统图像列表原生尺寸,不经缩放)。
|
||||||
|
pixels ??= LoadIconViaShellFileInfo(path, isDirectory, logicalSize);
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (pixels is null) return null;
|
||||||
|
|
||||||
|
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_nativeThrottle.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>扩展名通用图标:SHGFI_USEFILEATTRIBUTES + FILE_ATTRIBUTE_NORMAL,不访问磁盘。</summary>
|
||||||
|
private Task<ImageSource?> LoadExtensionIconCoreAsync(string extension, int logicalSize, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return RunSafeAsync(async () =>
|
||||||
|
{
|
||||||
|
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 路径传 "x.ext" 纯粹是为了让外壳从扩展名推断类型;
|
||||||
|
// SHGFI_USEFILEATTRIBUTES 保证它不会去访问磁盘(这也是它比按路径取快一个数量级的原因)。
|
||||||
|
string fakePath = "x." + extension;
|
||||||
|
|
||||||
|
BgraBuffer? pixels = LoadIconViaShellFileInfoCore(
|
||||||
|
fakePath,
|
||||||
|
ShellNative.FILE_ATTRIBUTE_NORMAL,
|
||||||
|
ShellNative.SHGFI_USEFILEATTRIBUTES,
|
||||||
|
logicalSize);
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (pixels is null) return null;
|
||||||
|
|
||||||
|
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_nativeThrottle.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 缩略图:SHCreateItemFromParsingName + IShellItemImageFactory.GetImage。
|
||||||
|
/// 失败一律返回 null(不抛异常);E_PENDING 表示外壳正在后台解码,等 200ms 重试,最多 3 次。
|
||||||
|
/// </summary>
|
||||||
|
private Task<ImageSource?> LoadThumbnailCoreAsync(string path, int logicalSize, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return RunSafeAsync(async () =>
|
||||||
|
{
|
||||||
|
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int pixelSize = ToPixelSize(logicalSize);
|
||||||
|
|
||||||
|
Guid iid = typeof(ShellNative.IShellItemImageFactory).GUID;
|
||||||
|
ShellNative.IShellItemImageFactory? factory = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int hr = ShellNative.SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out factory);
|
||||||
|
if (hr < 0 || factory is null)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] SHCreateItemFromParsingName 失败 0x{hr:X8}: {path}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// THUMBNAILONLY:没有真实缩略图就直接失败(交给上层显示图标);
|
||||||
|
// BIGGERSIZEOK:允许外壳返回更大的缓存图,由 XAML 侧缩放,反而更清晰。
|
||||||
|
const int flags = ShellNative.SIIGBF_THUMBNAILONLY | ShellNative.SIIGBF_BIGGERSIZEOK;
|
||||||
|
|
||||||
|
BgraBuffer? pixels = null;
|
||||||
|
for (int attempt = 0; attempt <= ThumbnailRetryCount; attempt++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var size = new ShellNative.SIZE(pixelSize, pixelSize);
|
||||||
|
int hrImage = factory.GetImage(size, flags, out IntPtr hBitmap);
|
||||||
|
|
||||||
|
if (hrImage == ShellNative.E_PENDING)
|
||||||
|
{
|
||||||
|
// 外壳正在生成缩略图:等一下再来
|
||||||
|
await Task.Delay(ThumbnailRetryDelayMs, ct).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hrImage < 0 || hBitmap == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
// 没有缩略图(例如 .txt / 未知类型)——这是正常情况,不记错误
|
||||||
|
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pixels = ShellNative.BitmapToBgra(hBitmap, pixelSize, pixelSize);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// IShellItemImageFactory 返回的 HBITMAP 归调用方所有,必须释放
|
||||||
|
ShellNative.DeleteObject(hBitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (pixels is null) return null;
|
||||||
|
|
||||||
|
// 空位图保护:尺寸为 0 或无像素时不产出 ImageSource
|
||||||
|
if (pixels.Width <= 0 || pixels.Height <= 0 || pixels.Pixels.Length == 0) return null;
|
||||||
|
|
||||||
|
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (factory is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(factory);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// 已被释放:忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_nativeThrottle.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 各条取图路径的 Win32 细节
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>SHGetFileInfoW 路径:目录走真实路径(保角标/个性化图标),文件走属性推断。</summary>
|
||||||
|
private BgraBuffer? LoadIconViaShellFileInfo(string path, bool isDirectory, int logicalSize)
|
||||||
|
{
|
||||||
|
if (isDirectory)
|
||||||
|
{
|
||||||
|
// 目录必须用真实路径:这样 OneDrive / 共享 / 快捷方式角标才会被带上
|
||||||
|
return LoadIconViaShellFileInfoCore(path, ShellNative.FILE_ATTRIBUTE_DIRECTORY, 0, logicalSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件用 FILE_ATTRIBUTE_NORMAL 推断:不访问磁盘,速度极快。
|
||||||
|
// 注意路径仍然参与取值,所以 .exe 的嵌入图标、.lnk 的目标图标依然正确。
|
||||||
|
return LoadIconViaShellFileInfoCore(path, ShellNative.FILE_ATTRIBUTE_NORMAL, ShellNative.SHGFI_USEFILEATTRIBUTES, logicalSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SHGetFileInfoW 核心:先拿系统图像列表索引 → 按 size 选 SHIL 取对应尺寸 HICON;
|
||||||
|
/// 拿不到索引就退回 SHGetFileInfoW 直接给的 HICON(32x32)。
|
||||||
|
/// </summary>
|
||||||
|
private BgraBuffer? LoadIconViaShellFileInfoCore(string path, uint attributes, uint extraFlags, int logicalSize)
|
||||||
|
{
|
||||||
|
int pixelSize = ToPixelSize(logicalSize);
|
||||||
|
int imageListKind = SelectImageList(logicalSize);
|
||||||
|
|
||||||
|
IntPtr hIcon = IntPtr.Zero;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int index = ShellNative.GetSystemIconIndexByPath(path, attributes, extraFlags);
|
||||||
|
if (index >= 0)
|
||||||
|
{
|
||||||
|
hIcon = ShellNative.GetHIconFromSystemImageList(imageListKind, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hIcon == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
// 回退:直接取 HICON(32x32 或 16x16),大尺寸就走高质量缩放
|
||||||
|
uint iconFlags = logicalSize <= 16 ? ShellNative.SHGFI_SMALLICON : ShellNative.SHGFI_LARGEICON;
|
||||||
|
hIcon = ShellNative.GetHIconByPath(path, attributes, extraFlags | iconFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hIcon == IntPtr.Zero) return null;
|
||||||
|
|
||||||
|
return ShellNative.IconToBgra(hIcon, pixelSize, pixelSize);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// 每一次取到的 HICON 都必须销毁,否则 GDI 句柄会持续增长
|
||||||
|
if (hIcon != IntPtr.Zero) ShellNative.DestroyIcon(hIcon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>shell: / ::{CLSID} 解析名的图标:SHParseDisplayName → PIDL → 系统图像列表。</summary>
|
||||||
|
private BgraBuffer? LoadIconFromShellParsingName(string parsingName, int logicalSize)
|
||||||
|
{
|
||||||
|
IntPtr pidl = ShellNative.ParseDisplayNameToPidl(parsingName);
|
||||||
|
if (pidl == IntPtr.Zero) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int pixelSize = ToPixelSize(logicalSize);
|
||||||
|
int imageListKind = SelectImageList(logicalSize);
|
||||||
|
|
||||||
|
IntPtr hIcon = IntPtr.Zero;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int index = ShellNative.GetSystemIconIndexByPidl(pidl, 0);
|
||||||
|
if (index >= 0)
|
||||||
|
{
|
||||||
|
hIcon = ShellNative.GetHIconFromSystemImageList(imageListKind, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hIcon == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
uint iconFlags = logicalSize <= 16 ? ShellNative.SHGFI_SMALLICON : ShellNative.SHGFI_LARGEICON;
|
||||||
|
hIcon = ShellNative.GetHIconByPidl(pidl, iconFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hIcon == IntPtr.Zero) return null;
|
||||||
|
|
||||||
|
return ShellNative.IconToBgra(hIcon, pixelSize, pixelSize);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (hIcon != IntPtr.Zero) ShellNative.DestroyIcon(hIcon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// PIDL 由 SHParseDisplayName 用 CoTaskMemAlloc 分配,必须 CoTaskMemFree
|
||||||
|
ShellNative.CoTaskMemFree(pidl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>带 "::{CLSID}" 前缀但不带 shell: 前缀的解析名。</summary>
|
||||||
|
private BgraBuffer? LoadIconFromParsingName(string parsingName, int logicalSize)
|
||||||
|
=> LoadIconFromShellParsingName(parsingName, logicalSize);
|
||||||
|
|
||||||
|
/// <summary>IShellItemImageFactory + SIIGBF_ICONONLY:大尺寸文件/扩展名的高清原版图标。</summary>
|
||||||
|
private BgraBuffer? LoadIconViaShellItemImageFactory(string path, int logicalSize)
|
||||||
|
{
|
||||||
|
int pixelSize = ToPixelSize(logicalSize);
|
||||||
|
|
||||||
|
Guid iid = typeof(ShellNative.IShellItemImageFactory).GUID;
|
||||||
|
ShellNative.IShellItemImageFactory? factory = null;
|
||||||
|
IntPtr hBitmap = IntPtr.Zero;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int hr = ShellNative.SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out factory);
|
||||||
|
if (hr < 0 || factory is null) return null;
|
||||||
|
|
||||||
|
// ICONONLY:只要图标不要缩略图;BIGGERSIZEOK:允许外壳给更大的原版图标
|
||||||
|
const int flags = ShellNative.SIIGBF_ICONONLY | ShellNative.SIIGBF_BIGGERSIZEOK;
|
||||||
|
|
||||||
|
hr = factory.GetImage(new ShellNative.SIZE(pixelSize, pixelSize), flags, out hBitmap);
|
||||||
|
if (hr < 0 || hBitmap == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShellNative.BitmapToBgra(hBitmap, pixelSize, pixelSize);
|
||||||
|
}
|
||||||
|
catch (COMException ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] IShellItemImageFactory(icon) 0x{ex.HResult:X8}: {path}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
|
||||||
|
if (factory is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(factory);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// 已被释放:忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 像素 → WinUI ImageSource(唯一需要 UI 线程的一段)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BGRA 像素 → <see cref="SoftwareBitmapSource"/>。
|
||||||
|
/// SoftwareBitmap 可在任意线程构造,但 SoftwareBitmapSource 必须在 UI 线程创建并 SetBitmapAsync,
|
||||||
|
/// 因此这里统一通过 <see cref="_uiDispatcher"/> 派回 UI 线程。
|
||||||
|
/// </summary>
|
||||||
|
private Task<ImageSource?> CreateImageSourceAsync(BgraBuffer buffer)
|
||||||
|
{
|
||||||
|
// SoftwareBitmap 是自由线程的,先在当前(后台)线程建好。
|
||||||
|
// 用 DataWriter 把 BGRA 字节装进 WinRT IBuffer(比 byte[].AsBuffer() 更省一次拷贝,
|
||||||
|
// 而且后者依赖 System.Runtime.InteropServices.WindowsRuntime 扩展,在部分 TFM 下不可用)。
|
||||||
|
var writer = new DataWriter();
|
||||||
|
writer.WriteBytes(buffer.Pixels);
|
||||||
|
IBuffer winrtBuffer = writer.DetachBuffer();
|
||||||
|
|
||||||
|
var bitmap = SoftwareBitmap.CreateCopyFromBuffer(
|
||||||
|
winrtBuffer,
|
||||||
|
BitmapPixelFormat.Bgra8,
|
||||||
|
buffer.Width,
|
||||||
|
buffer.Height,
|
||||||
|
// 图标/缩略图带 alpha,必须用预乘格式,否则半透明边缘会出现黑边
|
||||||
|
BitmapAlphaMode.Premultiplied);
|
||||||
|
|
||||||
|
return RunOnUiThreadAsync(async () =>
|
||||||
|
{
|
||||||
|
var source = new SoftwareBitmapSource();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await source.SetBitmapAsync(bitmap);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// SetBitmapAsync 会拷贝像素,之后即可释放 SoftwareBitmap
|
||||||
|
bitmap.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (ImageSource)source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把一段"必须跑在 UI 线程"的工作派回去执行,用 TaskCompletionSource 桥接。
|
||||||
|
/// 如果调用方已经在 UI 线程上,则直接同步执行,省掉一次调度往返。
|
||||||
|
/// </summary>
|
||||||
|
private Task<T?> RunOnUiThreadAsync<T>(Func<Task<T?>> work) where T : class
|
||||||
|
{
|
||||||
|
var dispatcher = _uiDispatcher;
|
||||||
|
if (dispatcher is null)
|
||||||
|
{
|
||||||
|
// 没有调度器(构造时未注入且当前线程也没有):退化为直接在调用线程创建,
|
||||||
|
// 总比直接失败好 —— 调用方若本来就在 UI 线程,这里完全正确。
|
||||||
|
return work();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatcher.HasThreadAccess)
|
||||||
|
{
|
||||||
|
// 已在 UI 线程:直接执行(SoftwareBitmapSource 亲和 UI 线程,此处满足条件)
|
||||||
|
return work();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<T?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
bool enqueued;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
enqueued = dispatcher.TryEnqueue(async void () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(await work().ConfigureAwait(true));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 派回 UI 线程的工作失败:以异常结束 Task,由上层 RunSafeAsync 统一吞掉
|
||||||
|
tcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is COMException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// DispatcherQueue 正在关闭(应用退出中)
|
||||||
|
Debug.WriteLine($"[ShellIconService] TryEnqueue 失败: {ex.Message}");
|
||||||
|
return Task.FromResult<T?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enqueued)
|
||||||
|
{
|
||||||
|
Debug.WriteLine("[ShellIconService] TryEnqueue 返回 false(消息循环已停止)");
|
||||||
|
return Task.FromResult<T?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 缓存 / 并发 / 异常兜底
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 同键请求合并 + LRU 缓存。
|
||||||
|
/// Lazy<Task> 保证同一个键只会解码一次,其余调用方 await 同一个 Task(快速滚动不会重复解码)。
|
||||||
|
/// </summary>
|
||||||
|
private static Task<ImageSource?> GetOrAddAsync(
|
||||||
|
LruCache<Task<ImageSource?>> cache,
|
||||||
|
string key,
|
||||||
|
Func<CancellationToken, Task<ImageSource?>> factory,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 缓存命中时必须尊重取消:已取消就直接返回 null
|
||||||
|
if (cache.TryGet(key, out var cachedTask))
|
||||||
|
{
|
||||||
|
return ct.IsCancellationRequested ? Task.FromResult<ImageSource?>(null) : cachedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意 T 是 Task<ImageSource?>:Lazy 的工厂返回的就是这个共享 Task
|
||||||
|
var lazy = cache.GetOrCreate(
|
||||||
|
key,
|
||||||
|
_ => new Lazy<Task<ImageSource?>>(
|
||||||
|
() => factory(CancellationToken.None),
|
||||||
|
LazyThreadSafetyMode.ExecutionAndPublication));
|
||||||
|
|
||||||
|
return AwaitLazyAsync(lazy, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>等待共享的 Lazy<Task>,但让"本次调用"的取消能立刻返回 null(不打断别人共享的那次解码)。</summary>
|
||||||
|
private static async Task<ImageSource?> AwaitLazyAsync(Lazy<Task<ImageSource?>> lazy, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Task<ImageSource?> task;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
task = lazy.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Lazy 的工厂本身抛异常(极少见,主要来自 factory 构造阶段)
|
||||||
|
Debug.WriteLine($"[ShellIconService] 缓存工厂异常: {ex}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.IsCompleted)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) return null;
|
||||||
|
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ct.CanBeCanceled)
|
||||||
|
{
|
||||||
|
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
using (ct.Register(static state => ((CancellationTokenSource)state!).Cancel(), cts))
|
||||||
|
{
|
||||||
|
var cancelTask = Task.Delay(Timeout.Infinite, cts.Token);
|
||||||
|
var finished = await Task.WhenAny(task, cancelTask).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (finished != task)
|
||||||
|
{
|
||||||
|
// 本调用被取消:吞掉 OperationCanceledException 语义,返回 null。
|
||||||
|
// 注意共享的那次解码仍在后台继续,其它调用方仍能拿到结果。
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// await 共享 Task 的最后一层兜底。
|
||||||
|
/// 正常情况这里不会抛(各 Load*CoreAsync 都套了 RunSafeAsync),
|
||||||
|
/// 但共享 Task 一旦带异常,所有同键调用方都会中招,所以在出口再兜一次,确保公开 API 永不外抛。
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<ImageSource?> AwaitSharedTaskAsync(Task<ImageSource?> task)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] 共享取图任务异常: {ex}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务层统一兜底:取消 → null;其它任何异常(含 COMException / SEHException / 未知)记 Debug 后返回 null。
|
||||||
|
/// 这里绝不让异常逃出去把 UI 线程打崩。
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<ImageSource?> RunSafeAsync(Func<Task<ImageSource?>> work)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await work().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 取消是正常路径(列表滚过去了),静默返回 null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] 取图失败: {ex}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>逻辑尺寸归一到合法范围(16..1024)。</summary>
|
||||||
|
private static int NormalizeSize(int size)
|
||||||
|
{
|
||||||
|
if (size <= 0) return 32;
|
||||||
|
if (size < 16) return 16;
|
||||||
|
if (size > 1024) return 1024;
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>归一化扩展名:去掉前导点、转小写;空则返回空串。</summary>
|
||||||
|
private static string NormalizeExtension(string? extension)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(extension)) return string.Empty;
|
||||||
|
string ext = extension.Trim();
|
||||||
|
if (ext.StartsWith('.')) ext = ext[1..];
|
||||||
|
return ext.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按显示尺寸 × DPI 缩放算出实际渲染像素尺寸(保证高 DPI 不糊)。</summary>
|
||||||
|
private int ToPixelSize(int logicalSize)
|
||||||
|
{
|
||||||
|
double scale = 1.0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scale = _scaleProvider();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[ShellIconService] rasterizationScale 取值失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (double.IsNaN(scale) || double.IsInfinity(scale) || scale <= 0) scale = 1.0;
|
||||||
|
if (scale > 4.0) scale = 4.0;
|
||||||
|
|
||||||
|
int pixels = (int)Math.Round(logicalSize * scale, MidpointRounding.AwayFromZero);
|
||||||
|
if (pixels < 1) pixels = 1;
|
||||||
|
if (pixels > 2048) pixels = 2048;
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按显示尺寸挑系统图像列表,保证拿到的是「原生该尺寸」的图标,而不是放大出来的。
|
||||||
|
/// 实测各列表的 HICON 原生尺寸:SHIL_LARGE(0)=32、SHIL_SMALL(1)=16、SHIL_EXTRALARGE(2)=48、SHIL_JUMBO(4)=256。
|
||||||
|
/// </summary>
|
||||||
|
private static int SelectImageList(int logicalSize) => logicalSize switch
|
||||||
|
{
|
||||||
|
>= 256 => ShellNative.SHIL_JUMBO,
|
||||||
|
>= 48 => ShellNative.SHIL_EXTRALARGE,
|
||||||
|
>= 32 => ShellNative.SHIL_LARGE,
|
||||||
|
_ => ShellNative.SHIL_SYSSMALL,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string BuildIconKey(string path, bool isDirectory, int logicalSize)
|
||||||
|
=> (isDirectory ? "dir|" : "file|") + logicalSize.ToString() + "|" + path;
|
||||||
|
|
||||||
|
/// <summary>判断是否为已知外壳解析名(shell: 或 ::{CLSID})。</summary>
|
||||||
|
private static bool IsSpecialParsingName(string path)
|
||||||
|
=> ShellParsingName.IsShellPrefix(path) || path.StartsWith("::", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 极简 LRU 缓存:ConcurrentDictionary 负责高并发读取,LinkedList 记录使用顺序,
|
||||||
|
/// 超出容量后从链表尾部淘汰最少使用的项。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">缓存值类型(本工程里是 ImageSource?)。</typeparam>
|
||||||
|
internal sealed class LruCache<T>
|
||||||
|
{
|
||||||
|
private readonly int _capacity;
|
||||||
|
private readonly ConcurrentDictionary<string, Entry> _map = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly LinkedList<string> _order = new();
|
||||||
|
private readonly object _orderGate = new();
|
||||||
|
|
||||||
|
internal LruCache(int capacity)
|
||||||
|
{
|
||||||
|
_capacity = capacity > 0 ? capacity : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Entry
|
||||||
|
{
|
||||||
|
internal Entry(Lazy<T> value) => Value = value;
|
||||||
|
|
||||||
|
internal Lazy<T> Value { get; }
|
||||||
|
|
||||||
|
internal LinkedListNode<string>? Node { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取缓存值(命中即刷新使用顺序)。</summary>
|
||||||
|
internal bool TryGet(string key, out T value)
|
||||||
|
{
|
||||||
|
if (_map.TryGetValue(key, out var entry))
|
||||||
|
{
|
||||||
|
Touch(entry);
|
||||||
|
value = entry.Value.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 取或创建。注意这里返回的是 Lazy<T> 本身:同一键的并发调用方会拿到同一个实例,
|
||||||
|
/// 因此 T 为 Task 时天然实现了"同键请求合并"。
|
||||||
|
/// </summary>
|
||||||
|
internal Lazy<T> GetOrCreate(string key, Func<string, Lazy<T>> factory)
|
||||||
|
{
|
||||||
|
var entry = _map.GetOrAdd(key, k => new Entry(factory(k)));
|
||||||
|
|
||||||
|
if (entry.Node is null)
|
||||||
|
{
|
||||||
|
lock (_orderGate)
|
||||||
|
{
|
||||||
|
if (entry.Node is null)
|
||||||
|
{
|
||||||
|
entry.Node = _order.AddFirst(key);
|
||||||
|
Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Clear()
|
||||||
|
{
|
||||||
|
lock (_orderGate)
|
||||||
|
{
|
||||||
|
_map.Clear();
|
||||||
|
_order.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把命中的键移到链表头部(最近使用)。</summary>
|
||||||
|
private void Touch(Entry entry)
|
||||||
|
{
|
||||||
|
var node = entry.Node;
|
||||||
|
if (node is null) return;
|
||||||
|
|
||||||
|
lock (_orderGate)
|
||||||
|
{
|
||||||
|
if (node.List is null) return;
|
||||||
|
_order.Remove(node);
|
||||||
|
_order.AddFirst(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>超出容量时从尾部淘汰(调用方已持有 _orderGate)。</summary>
|
||||||
|
private void Trim()
|
||||||
|
{
|
||||||
|
while (_order.Count > _capacity)
|
||||||
|
{
|
||||||
|
var last = _order.Last;
|
||||||
|
if (last is null) return;
|
||||||
|
|
||||||
|
_order.RemoveLast();
|
||||||
|
if (_map.TryRemove(last.Value, out var removed))
|
||||||
|
{
|
||||||
|
// 置空节点引用,防止被淘汰的 Entry 仍被 Remove 时的 node.List 判空逻辑误解
|
||||||
|
removed.Node = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
namespace FluidExplorer.Services.Icons;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 外壳解析名(parsing name)的小工具。
|
||||||
|
/// 资源管理器/地址栏里出现的 "shell:RecycleBinFolder"、"{20D04FE0-3AEA-1069-A2D8-08002B30309D}"(此电脑)
|
||||||
|
/// 都不是文件系统路径,必须先经 SHParseDisplayName 解析成 PIDL 才能取图标。
|
||||||
|
/// </summary>
|
||||||
|
internal static class ShellParsingName
|
||||||
|
{
|
||||||
|
/// <summary>"shell:" 前缀(大小写不敏感)。</summary>
|
||||||
|
internal const string ShellPrefix = "shell:";
|
||||||
|
|
||||||
|
/// <summary>是不是 "shell:xxx" 形式的解析名。</summary>
|
||||||
|
internal static bool IsShellPrefix(string? parsingName)
|
||||||
|
=> !string.IsNullOrEmpty(parsingName)
|
||||||
|
&& parsingName.StartsWith(ShellPrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>是不是 "::{CLSID}" 形式的解析名(此电脑、回收站等已知外壳对象的经典写法)。</summary>
|
||||||
|
internal static bool IsGuidPidl(string? parsingName)
|
||||||
|
=> !string.IsNullOrEmpty(parsingName)
|
||||||
|
&& parsingName.StartsWith("::", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>本工程支持的外壳解析名(取图标时可以走 PIDL 路径)。</summary>
|
||||||
|
internal static bool IsParsingName(string? parsingName)
|
||||||
|
=> IsShellPrefix(parsingName) || IsGuidPidl(parsingName);
|
||||||
|
|
||||||
|
/// <summary>补全 "shell:" 前缀:传入 "RecycleBinFolder" 也能用。</summary>
|
||||||
|
internal static string Normalize(string parsingName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(parsingName)) return string.Empty;
|
||||||
|
|
||||||
|
string trimmed = parsingName.Trim();
|
||||||
|
return IsParsingName(trimmed) ? trimmed : ShellPrefix + trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常用已知外壳文件夹的解析名(全部是 Windows 自带原版对象)
|
||||||
|
internal const string RecycleBin = "shell:RecycleBinFolder";
|
||||||
|
internal const string ThisPcClassic = "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}";
|
||||||
|
internal const string ThisPc = "shell:MyComputerFolder";
|
||||||
|
internal const string Network = "shell:NetworkPlacesFolder";
|
||||||
|
internal const string UserProfile = "shell:UserProfile";
|
||||||
|
internal const string Desktop = "shell:Desktop";
|
||||||
|
internal const string Downloads = "shell:Downloads";
|
||||||
|
internal const string Documents = "shell:Personal";
|
||||||
|
internal const string Pictures = "shell:MyPictures";
|
||||||
|
internal const string Music = "shell:MyMusic";
|
||||||
|
internal const string Videos = "shell:MyVideos";
|
||||||
|
internal const string ControlPanel = "shell:ControlPanelFolder";
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
using FluidExplorer.Models;
|
||||||
|
using FluidExplorer.Services.Icons;
|
||||||
|
using FluidExplorer.ViewModels;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.ItemVisuals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 列表里每一行的图标/缩略图按需加载:只为真正可见的行发请求,
|
||||||
|
/// 内置并发上限与去重,滚动再快也不会把外壳图标接口打爆。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ItemVisualService(IIconService icons, DispatcherQueue ui)
|
||||||
|
{
|
||||||
|
/// <summary>诊断开关:FLUID_DISABLE_ICONS=1 时完全跳过外壳取图(用于隔离图标路径引发的问题)。</summary>
|
||||||
|
private static readonly bool IconsDisabled = Environment.GetEnvironmentVariable("FLUID_DISABLE_ICONS") == "1";
|
||||||
|
|
||||||
|
private static readonly HashSet<string> ThumbnailExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "heic", "heif", "avif", "ico", "svg",
|
||||||
|
"mp4", "mkv", "avi", "mov", "wmv", "m4v", "webm", "mpg", "mpeg", "ts",
|
||||||
|
"pdf"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _iconGate = new(6, 6);
|
||||||
|
private readonly SemaphoreSlim _thumbGate = new(3, 3);
|
||||||
|
private readonly HashSet<string> _inFlight = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly object _sync = new();
|
||||||
|
|
||||||
|
public bool IsThumbnailCandidate(string extension) => ThumbnailExtensions.Contains(extension);
|
||||||
|
|
||||||
|
/// <summary>为一行请求小图标(列表/详情视图)。</summary>
|
||||||
|
public void RequestIcon(ExplorerItem item, int size = 32)
|
||||||
|
{
|
||||||
|
if (IconsDisabled) return;
|
||||||
|
var key = "i|" + item.FullPath + "|" + size;
|
||||||
|
if (!TryBegin(key)) return;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var source = await icons.GetIconAsync(item.FullPath, item.IsDirectory, size, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (source is null && !item.IsDirectory)
|
||||||
|
source = await icons.GetExtensionIconAsync(item.Extension, size, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (source is null) return;
|
||||||
|
ui.TryEnqueue(() => { try { item.Icon = source; } catch (Exception ex) { App.Log($"设置图标失败: {ex.Message}"); } });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 图标失败不影响使用
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_iconGate.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>为一行请求缩略图(网格视图);失败则保留图标。</summary>
|
||||||
|
public void RequestThumbnail(ExplorerItem item, int size)
|
||||||
|
{
|
||||||
|
if (IconsDisabled) return;
|
||||||
|
if (item.IsDirectory || !IsThumbnailCandidate(item.Extension))
|
||||||
|
{
|
||||||
|
RequestIcon(item, Math.Min(size, 96));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = "t|" + item.FullPath + "|" + size;
|
||||||
|
if (!TryBeginThumb(key)) return;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var source = await icons.GetThumbnailAsync(item.FullPath, size, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
source = await icons.GetIconAsync(item.FullPath, false, Math.Min(size, 96), CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (source is null) return;
|
||||||
|
ui.TryEnqueue(() => { try { item.Icon = source; } catch (Exception ex) { App.Log($"设置图标失败: {ex.Message}"); } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
item.HasThumbnail = true;
|
||||||
|
item.Icon = source;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Log($"设置缩略图失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_thumbGate.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestIcon(SearchResultItem item, int size = 32)
|
||||||
|
{
|
||||||
|
if (IconsDisabled) return;
|
||||||
|
var key = "si|" + item.FullPath + "|" + size;
|
||||||
|
if (!TryBegin(key)) return;
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var source = await icons.GetIconAsync(item.FullPath, item.IsDirectory, size, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (source is null && !item.IsDirectory)
|
||||||
|
source = await icons.GetExtensionIconAsync(item.Hit.Extension, size, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (source is null) return;
|
||||||
|
ui.TryEnqueue(() => { try { item.Icon = source; } catch (Exception ex) { App.Log($"设置图标失败: {ex.Message}"); } });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_iconGate.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>导航离开时清掉去重表(已缓存的位图仍在图标服务里)。</summary>
|
||||||
|
public void ResetPending()
|
||||||
|
{
|
||||||
|
lock (_sync) _inFlight.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryBegin(string key)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (!_inFlight.Add(key)) return false;
|
||||||
|
}
|
||||||
|
if (!_iconGate.Wait(0))
|
||||||
|
{
|
||||||
|
lock (_sync) _inFlight.Remove(key);
|
||||||
|
// 让给其它行,稍后由可见性变化再次触发
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryBeginThumb(string key)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (!_inFlight.Add(key)) return false;
|
||||||
|
}
|
||||||
|
if (!_thumbGate.Wait(0))
|
||||||
|
{
|
||||||
|
lock (_sync) _inFlight.Remove(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Operations;
|
||||||
|
|
||||||
|
/// <summary>单个条目(文件/目录)处理后的结果。</summary>
|
||||||
|
internal enum EntryOutcome
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
Skipped,
|
||||||
|
Failed,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 拷贝引擎与作业运行器之间的回调边界。
|
||||||
|
/// 引擎本身不认识 FileOperationJob / UI,只通过这个接口上报进度、询问冲突、请求取消,
|
||||||
|
/// 因此可以脱离队列单独测试与复用。
|
||||||
|
/// </summary>
|
||||||
|
internal interface IJobSink
|
||||||
|
{
|
||||||
|
CancellationToken Token { get; }
|
||||||
|
|
||||||
|
bool IsCancellationRequested { get; }
|
||||||
|
|
||||||
|
/// <summary>若作业处于暂停态则挂起,直到继续或取消。每个拷贝块之间调用。</summary>
|
||||||
|
Task WaitIfPausedAsync();
|
||||||
|
|
||||||
|
void AddBytes(long delta);
|
||||||
|
|
||||||
|
void AddCompletedItems(int delta);
|
||||||
|
|
||||||
|
void SetCurrentItem(string path);
|
||||||
|
|
||||||
|
/// <summary>记录一条非致命警告(重解析点跳过、时间戳设置失败等),进 job.Error。</summary>
|
||||||
|
void Warn(string message);
|
||||||
|
|
||||||
|
/// <summary>记录一个失败条目;引擎会继续处理其余文件,绝不中止整批。</summary>
|
||||||
|
void AddFailed(string path, string reason);
|
||||||
|
|
||||||
|
void AddSkipped(int delta = 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记录一次真实的"搬运"以便撤销。
|
||||||
|
/// 语义:<paramref name="newPath"/> 是搬运后的当前位置,<paramref name="originalPath"/> 是原位置;
|
||||||
|
/// 撤销时把 newPath 搬回 originalPath。(同卷移动是 1 条;目录合并移动会产生多条。)
|
||||||
|
/// </summary>
|
||||||
|
void RecordMoveForUndo(string newPath, string originalPath);
|
||||||
|
|
||||||
|
/// <summary>向 UI 询问冲突处理方式。未设置回调 / 非 Ask 策略时由运行器按 Policy 直接决定,不阻塞。</summary>
|
||||||
|
Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info);
|
||||||
|
|
||||||
|
/// <summary>用户选择了"取消",终止整个作业(已完成的部分保留,不回滚)。</summary>
|
||||||
|
void RequestCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件复制 / 移动 / 删除的核心实现。
|
||||||
|
///
|
||||||
|
/// 关键设计:
|
||||||
|
/// - 所有 Win32 文件 IO 走 \\?\ 长路径前缀(见 <see cref="PathHelper"/>);
|
||||||
|
/// - 1MB 缓冲 + SequentialScan + 异步 IO,块与块之间检查暂停/取消;
|
||||||
|
/// - 单文件失败只重试 3 次(100/300/900ms)后计入失败列表并继续,绝不因单个文件中断整批;
|
||||||
|
/// - 同卷 Move 走 File.Move/Directory.Move(瞬时、不搬字节),跨卷才 Copy+Delete;
|
||||||
|
/// 之所以不用 MoveFileEx/直接调 API:那样虽然也能跨卷搬,但拿不到字节级进度,
|
||||||
|
/// 而"精确进度 + 可暂停/取消"是本引擎的核心诉求。
|
||||||
|
/// </summary>
|
||||||
|
internal static class CopyEngine
|
||||||
|
{
|
||||||
|
/// <summary>拷贝块大小:1MB。</summary>
|
||||||
|
internal const int BufferSize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>重试间隔(毫秒):共重试 3 次。</summary>
|
||||||
|
private static readonly int[] RetryDelaysMs = [100, 300, 900];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 测量
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归统计总字节数与总条目数(目录本身也算 1 个条目,和执行阶段的计数口径一致)。
|
||||||
|
/// 不跟随重解析点;无法访问的项跳过。可取消。
|
||||||
|
/// </summary>
|
||||||
|
internal static (long Bytes, int Items) Measure(IReadOnlyList<string> paths, CancellationToken cancellationToken, Action<string>? onWarning = null)
|
||||||
|
{
|
||||||
|
long bytes = 0;
|
||||||
|
var items = 0;
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) return (bytes, items);
|
||||||
|
|
||||||
|
var attrs = PathHelper.TryGetAttributes(path);
|
||||||
|
if (attrs is null)
|
||||||
|
{
|
||||||
|
onWarning?.Invoke($"测量时跳过无法访问的项:{path}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
onWarning?.Invoke($"测量时跳过重解析点:{path}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attrs & FileAttributes.Directory) == 0)
|
||||||
|
{
|
||||||
|
bytes += PathHelper.TryGetLength(path);
|
||||||
|
items++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stack = new Stack<string>();
|
||||||
|
stack.Push(path);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) return (bytes, items);
|
||||||
|
|
||||||
|
var dir = stack.Pop();
|
||||||
|
items++;
|
||||||
|
|
||||||
|
foreach (var child in PathHelper.EnumerateChildrenSafe(dir, onWarning))
|
||||||
|
{
|
||||||
|
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||||
|
if (childAttrs is null) continue;
|
||||||
|
if ((childAttrs & FileAttributes.ReparsePoint) != 0) continue; // 不跟随,避免无限递归
|
||||||
|
|
||||||
|
if ((childAttrs & FileAttributes.Directory) != 0) stack.Push(child);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bytes += PathHelper.TryGetLength(child);
|
||||||
|
items++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bytes, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 复制
|
||||||
|
|
||||||
|
/// <summary>复制一个条目(文件或目录),内部处理冲突策略。</summary>
|
||||||
|
internal static async Task<EntryOutcome> CopyEntryAsync(string source, string destination, IJobSink sink)
|
||||||
|
{
|
||||||
|
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
var attrs = PathHelper.TryGetAttributes(source);
|
||||||
|
if (attrs is null)
|
||||||
|
{
|
||||||
|
sink.AddFailed(source, "源不存在或无法访问。");
|
||||||
|
return EntryOutcome.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||||
|
sink.AddSkipped();
|
||||||
|
return EntryOutcome.Skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||||
|
|
||||||
|
if (PathHelper.Exists(destination))
|
||||||
|
{
|
||||||
|
var resolution = await sink.ResolveConflictAsync(BuildConflictInfo(source, destination)).ConfigureAwait(false);
|
||||||
|
switch (resolution)
|
||||||
|
{
|
||||||
|
case ConflictResolution.Cancel:
|
||||||
|
sink.RequestCancel();
|
||||||
|
return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
case ConflictResolution.Skip:
|
||||||
|
sink.AddSkipped();
|
||||||
|
return EntryOutcome.Skipped;
|
||||||
|
|
||||||
|
case ConflictResolution.KeepBoth:
|
||||||
|
destination = PathHelper.MakeUniquePath(destination);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // Replace:目录对目录 = 合并;文件对文件 = 先删目标再复制
|
||||||
|
var destinationIsDir = PathHelper.DirectoryExists(destination);
|
||||||
|
if (sourceIsDir && destinationIsDir) break; // 合并,保留目标目录
|
||||||
|
if (sourceIsDir != destinationIsDir)
|
||||||
|
{
|
||||||
|
sink.AddFailed(source, sourceIsDir
|
||||||
|
? "目标位置存在同名文件,无法用文件夹替换文件。"
|
||||||
|
: "目标位置存在同名文件夹,无法用文件替换文件夹。");
|
||||||
|
return EntryOutcome.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed = await DeletePermanentAsync(destination, sink, countAsItem: false).ConfigureAwait(false);
|
||||||
|
if (removed != EntryOutcome.Success) return removed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceIsDir
|
||||||
|
? await CopyDirectoryAsync(source, destination, sink).ConfigureAwait(false)
|
||||||
|
: await CopyFileAsync(source, destination, sink).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<EntryOutcome> CopyFileAsync(string source, string destination, IJobSink sink)
|
||||||
|
{
|
||||||
|
long attemptBytes = 0;
|
||||||
|
var ok = await RetryAsync(
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
attemptBytes = 0;
|
||||||
|
sink.SetCurrentItem(source);
|
||||||
|
PathHelper.EnsureParentDirectory(destination);
|
||||||
|
await CopyFileCoreAsync(source, destination, sink,
|
||||||
|
n =>
|
||||||
|
{
|
||||||
|
attemptBytes += n; // 重试时用于回退已上报的字节数
|
||||||
|
sink.AddBytes(n); // 真正的进度上报(限频由 sink 负责)
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
sink,
|
||||||
|
onRetry: () => { if (attemptBytes > 0) sink.AddBytes(-attemptBytes); }).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
sink.AddCompletedItems(1);
|
||||||
|
return EntryOutcome.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sink.IsCancellationRequested ? EntryOutcome.Cancelled : EntryOutcome.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CopyFileCoreAsync(string source, string destination, IJobSink sink, Action<long> reportBytes)
|
||||||
|
{
|
||||||
|
var sourceExtended = PathHelper.ToExtended(source);
|
||||||
|
var destinationExtended = PathHelper.ToExtended(destination);
|
||||||
|
|
||||||
|
// 目标已存在且只读:必须先去只读,否则 Create 会抛 UnauthorizedAccessException。
|
||||||
|
PathHelper.ClearReadOnly(destination);
|
||||||
|
|
||||||
|
var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (var input = new FileStream(sourceExtended, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||||
|
bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||||
|
await using (var output = new FileStream(destinationExtended, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||||
|
bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (sink.IsCancellationRequested) throw new OperationCanceledException(sink.Token);
|
||||||
|
|
||||||
|
var read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), sink.Token).ConfigureAwait(false);
|
||||||
|
if (read <= 0) break;
|
||||||
|
|
||||||
|
await output.WriteAsync(buffer.AsMemory(0, read), sink.Token).ConfigureAwait(false);
|
||||||
|
reportBytes(read);
|
||||||
|
}
|
||||||
|
|
||||||
|
await output.FlushAsync(sink.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留时间戳与属性(只读属性最后设置)。
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sourceAttrs = PathHelper.TryGetAttributes(source) ?? FileAttributes.Normal;
|
||||||
|
File.SetLastWriteTimeUtc(destinationExtended, File.GetLastWriteTimeUtc(sourceExtended));
|
||||||
|
File.SetCreationTimeUtc(destinationExtended, File.GetCreationTimeUtc(sourceExtended));
|
||||||
|
File.SetAttributes(destinationExtended,
|
||||||
|
sourceAttrs & ~(FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
sink.Warn($"已复制但无法保留时间戳/属性:{destination}({ex.Message})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<EntryOutcome> CopyDirectoryAsync(string source, string destination, IJobSink sink)
|
||||||
|
{
|
||||||
|
if (IsSameOrSubPathOf(destination, source))
|
||||||
|
{
|
||||||
|
sink.AddFailed(source, "目标路径位于源目录内部,已拒绝执行(会造成无限递归)。");
|
||||||
|
return EntryOutcome.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用显式栈做迭代式递归,避免极深目录树耗尽调用栈。
|
||||||
|
var stack = new Stack<(string Source, string Destination)>();
|
||||||
|
stack.Push((source, destination));
|
||||||
|
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
var (currentSource, currentDestination) = stack.Pop();
|
||||||
|
|
||||||
|
var created = await RetryAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
PathHelper.EnsureParentDirectory(currentDestination);
|
||||||
|
Directory.CreateDirectory(PathHelper.ToExtended(currentDestination));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
currentDestination,
|
||||||
|
sink).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!created) return EntryOutcome.Failed;
|
||||||
|
|
||||||
|
sink.SetCurrentItem(currentDestination);
|
||||||
|
sink.AddCompletedItems(1);
|
||||||
|
|
||||||
|
foreach (var child in PathHelper.EnumerateChildrenSafe(currentSource, sink.Warn))
|
||||||
|
{
|
||||||
|
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||||
|
if (childAttrs is null)
|
||||||
|
{
|
||||||
|
sink.AddFailed(child, "无法读取属性(可能已被删除或拒绝访问)。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((childAttrs & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{child}");
|
||||||
|
sink.AddSkipped();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = PathHelper.Combine(currentDestination, PathHelper.GetFileName(child));
|
||||||
|
if ((childAttrs & FileAttributes.Directory) != 0)
|
||||||
|
{
|
||||||
|
stack.Push((child, target));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var outcome = await CopyEntryAsync(child, target, sink).ConfigureAwait(false);
|
||||||
|
if (outcome == EntryOutcome.Cancelled) return EntryOutcome.Cancelled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EntryOutcome.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 移动
|
||||||
|
|
||||||
|
/// <summary>移动一个条目,内部处理冲突策略。</summary>
|
||||||
|
internal static async Task<EntryOutcome> MoveEntryAsync(string source, string destination, IJobSink sink)
|
||||||
|
{
|
||||||
|
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
var attrs = PathHelper.TryGetAttributes(source);
|
||||||
|
if (attrs is null)
|
||||||
|
{
|
||||||
|
sink.AddFailed(source, "源不存在或无法访问。");
|
||||||
|
return EntryOutcome.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||||
|
sink.AddSkipped();
|
||||||
|
return EntryOutcome.Skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||||
|
|
||||||
|
if (PathHelper.Exists(destination))
|
||||||
|
{
|
||||||
|
var resolution = await sink.ResolveConflictAsync(BuildConflictInfo(source, destination)).ConfigureAwait(false);
|
||||||
|
switch (resolution)
|
||||||
|
{
|
||||||
|
case ConflictResolution.Cancel:
|
||||||
|
sink.RequestCancel();
|
||||||
|
return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
case ConflictResolution.Skip:
|
||||||
|
sink.AddSkipped();
|
||||||
|
return EntryOutcome.Skipped;
|
||||||
|
|
||||||
|
case ConflictResolution.KeepBoth:
|
||||||
|
destination = PathHelper.MakeUniquePath(destination);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
var destinationIsDir = PathHelper.DirectoryExists(destination);
|
||||||
|
if (sourceIsDir && destinationIsDir) break; // 目录对目录:合并(递归搬运子项)
|
||||||
|
if (sourceIsDir != destinationIsDir)
|
||||||
|
{
|
||||||
|
sink.AddFailed(source, sourceIsDir
|
||||||
|
? "目标位置存在同名文件,无法用文件夹替换文件。"
|
||||||
|
: "目标位置存在同名文件夹,无法用文件替换文件夹。");
|
||||||
|
return EntryOutcome.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed = await DeletePermanentAsync(destination, sink, countAsItem: false).ConfigureAwait(false);
|
||||||
|
if (removed != EntryOutcome.Success) return removed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceIsDir && PathHelper.DirectoryExists(destination))
|
||||||
|
return await MoveDirectoryMergedAsync(source, destination, sink).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await MoveSingleAsync(source, destination, sourceIsDir, sink).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 不做冲突询问的移动(重命名、撤销还原用):
|
||||||
|
/// 调用方必须已经保证目标路径不冲突,或已经自行决定好冲突处理方式。
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<EntryOutcome> MoveDirectAsync(string source, string destination, IJobSink sink)
|
||||||
|
{
|
||||||
|
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
var attrs = PathHelper.TryGetAttributes(source);
|
||||||
|
if (attrs is null)
|
||||||
|
{
|
||||||
|
sink.AddFailed(source, "源不存在或无法访问。");
|
||||||
|
return EntryOutcome.Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attrs & FileAttributes.ReparsePoint) != 0)
|
||||||
|
{
|
||||||
|
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
|
||||||
|
sink.AddSkipped();
|
||||||
|
return EntryOutcome.Skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
|
||||||
|
|
||||||
|
if (sourceIsDir && PathHelper.DirectoryExists(destination))
|
||||||
|
return await MoveDirectoryMergedAsync(source, destination, sink).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await MoveSingleAsync(source, destination, sourceIsDir, sink).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<EntryOutcome> MoveSingleAsync(string source, string destination, bool sourceIsDir, IJobSink sink)
|
||||||
|
{
|
||||||
|
// 同卷:File.Move / Directory.Move 是纯元数据操作,瞬时完成,不产生任何字节流量。
|
||||||
|
if (PathHelper.SameVolume(source, destination))
|
||||||
|
{
|
||||||
|
var moved = await RetryAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
sink.SetCurrentItem(source);
|
||||||
|
PathHelper.EnsureParentDirectory(destination);
|
||||||
|
var s = PathHelper.ToExtended(source);
|
||||||
|
var d = PathHelper.ToExtended(destination);
|
||||||
|
if (sourceIsDir) Directory.Move(s, d);
|
||||||
|
else File.Move(s, d);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
sink).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (moved)
|
||||||
|
{
|
||||||
|
sink.AddCompletedItems(1);
|
||||||
|
sink.RecordMoveForUndo(destination, source);
|
||||||
|
return EntryOutcome.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
// 同卷判定失败(例如跨卷挂载点/Junction)时退化:复制成功后删源,仍有字节级进度。
|
||||||
|
sink.Warn($"同卷移动失败,自动改用“复制后删除”:{source}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var copied = sourceIsDir
|
||||||
|
? await CopyDirectoryAsync(source, destination, sink).ConfigureAwait(false)
|
||||||
|
: await CopyEntryAsync(source, destination, sink).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (copied != EntryOutcome.Success) return copied;
|
||||||
|
|
||||||
|
sink.RecordMoveForUndo(destination, source);
|
||||||
|
|
||||||
|
var deleted = await DeletePermanentAsync(source, sink, countAsItem: false).ConfigureAwait(false);
|
||||||
|
return deleted == EntryOutcome.Success ? EntryOutcome.Success : deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目录合并移动:目标目录已存在时,逐个搬子项。
|
||||||
|
/// 同卷时每个子项都是瞬时的 File.Move;同名子项按冲突策略处理。
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<EntryOutcome> MoveDirectoryMergedAsync(string source, string destination, IJobSink sink)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(PathHelper.ToExtended(destination));
|
||||||
|
|
||||||
|
foreach (var child in PathHelper.EnumerateChildrenSafe(source, sink.Warn))
|
||||||
|
{
|
||||||
|
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
var target = PathHelper.Combine(destination, PathHelper.GetFileName(child));
|
||||||
|
var outcome = await MoveEntryAsync(child, target, sink).ConfigureAwait(false);
|
||||||
|
if (outcome == EntryOutcome.Cancelled) return EntryOutcome.Cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
TryDeleteEmptyDirectory(source);
|
||||||
|
return EntryOutcome.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 删除
|
||||||
|
|
||||||
|
/// <summary>永久删除(不进回收站)。目录采用"后序迭代删除",逐个条目上报进度。</summary>
|
||||||
|
internal static async Task<EntryOutcome> DeletePermanentAsync(string path, IJobSink sink, bool countAsItem)
|
||||||
|
{
|
||||||
|
var attrs = PathHelper.TryGetAttributes(path);
|
||||||
|
if (attrs is null)
|
||||||
|
{
|
||||||
|
if (countAsItem) sink.AddFailed(path, "路径不存在或无法访问。");
|
||||||
|
return countAsItem ? EntryOutcome.Failed : EntryOutcome.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attrs & FileAttributes.Directory) == 0)
|
||||||
|
{
|
||||||
|
var ok = await RetryAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
sink.SetCurrentItem(path);
|
||||||
|
PathHelper.ClearReadOnly(path);
|
||||||
|
File.Delete(PathHelper.ToExtended(path));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
path,
|
||||||
|
sink).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!ok) return sink.IsCancellationRequested ? EntryOutcome.Cancelled : EntryOutcome.Failed;
|
||||||
|
if (countAsItem) sink.AddCompletedItems(1);
|
||||||
|
return EntryOutcome.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stack = new Stack<(string Path, bool Expanded)>();
|
||||||
|
stack.Push((path, false));
|
||||||
|
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
await sink.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
|
||||||
|
|
||||||
|
var (current, expanded) = stack.Pop();
|
||||||
|
|
||||||
|
if (!expanded)
|
||||||
|
{
|
||||||
|
stack.Push((current, true));
|
||||||
|
foreach (var child in PathHelper.EnumerateChildrenSafe(current, sink.Warn))
|
||||||
|
{
|
||||||
|
var childAttrs = PathHelper.TryGetAttributes(child);
|
||||||
|
if (childAttrs is null) continue;
|
||||||
|
|
||||||
|
// 重解析点:只删链接本身,绝不递归进去(否则会删掉链接目标的内容)。
|
||||||
|
if ((childAttrs & FileAttributes.ReparsePoint) != 0 || (childAttrs & FileAttributes.Directory) == 0)
|
||||||
|
stack.Push((child, true));
|
||||||
|
else
|
||||||
|
stack.Push((child, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDirectory = PathHelper.DirectoryExists(current);
|
||||||
|
var deleted = await RetryAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
sink.SetCurrentItem(current);
|
||||||
|
PathHelper.ClearReadOnly(current);
|
||||||
|
var extended = PathHelper.ToExtended(current);
|
||||||
|
if (isDirectory) Directory.Delete(extended, recursive: false);
|
||||||
|
else File.Delete(extended);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
current,
|
||||||
|
sink).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (deleted && countAsItem) sink.AddCompletedItems(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EntryOutcome.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 通用
|
||||||
|
|
||||||
|
/// <summary>重试包装:IOException / UnauthorizedAccessException 重试 3 次(100/300/900ms),
|
||||||
|
/// 仍失败则计入失败列表并返回 false,由调用方继续处理其余文件。</summary>
|
||||||
|
private static async Task<bool> RetryAsync(Func<Task> action, string path, IJobSink sink, Action? onRetry = null)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await action().ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (attempt < RetryDelaysMs.Length && IsTransient(ex))
|
||||||
|
{
|
||||||
|
onRetry?.Invoke();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(RetryDelaysMs[attempt], sink.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
onRetry?.Invoke();
|
||||||
|
sink.AddFailed(path, Describe(ex));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransient(Exception ex) => ex is IOException or UnauthorizedAccessException;
|
||||||
|
|
||||||
|
private static string Describe(Exception ex) => ex switch
|
||||||
|
{
|
||||||
|
UnauthorizedAccessException => "拒绝访问(文件可能被占用或权限不足)。",
|
||||||
|
DirectoryNotFoundException => "目录不存在(可能已被移动或删除)。",
|
||||||
|
FileNotFoundException => "文件不存在(可能已被移动或删除)。",
|
||||||
|
PathTooLongException => "路径过长。",
|
||||||
|
_ => ex.Message
|
||||||
|
};
|
||||||
|
|
||||||
|
internal static ConflictInfo BuildConflictInfo(string source, string destination)
|
||||||
|
{
|
||||||
|
var sourceAttrs = PathHelper.TryGetAttributes(source) ?? 0;
|
||||||
|
var destinationAttrs = PathHelper.TryGetAttributes(destination) ?? 0;
|
||||||
|
var sourceIsDir = (sourceAttrs & FileAttributes.Directory) != 0;
|
||||||
|
var destinationIsDir = (destinationAttrs & FileAttributes.Directory) != 0;
|
||||||
|
|
||||||
|
return new ConflictInfo
|
||||||
|
{
|
||||||
|
SourcePath = source,
|
||||||
|
DestinationPath = destination,
|
||||||
|
SourceIsDirectory = sourceIsDir,
|
||||||
|
SourceSize = sourceIsDir ? 0 : PathHelper.TryGetLength(source),
|
||||||
|
DestinationSize = destinationIsDir ? 0 : PathHelper.TryGetLength(destination),
|
||||||
|
SourceModifiedUtc = TryGetModifiedUtc(source),
|
||||||
|
DestinationModifiedUtc = TryGetModifiedUtc(destination)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime TryGetModifiedUtc(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.GetLastWriteTimeUtc(PathHelper.ToExtended(path));
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>candidate 是否等于 root 或位于 root 之内(用于拒绝"复制到自身内部")。</summary>
|
||||||
|
internal static bool IsSameOrSubPathOf(string candidate, string root)
|
||||||
|
{
|
||||||
|
var c = (PathHelper.TryGetFullPath(candidate) ?? candidate)
|
||||||
|
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var r = (PathHelper.TryGetFullPath(root) ?? root)
|
||||||
|
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
|
||||||
|
if (string.Equals(c, r, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
return c.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteEmptyDirectory(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extended = PathHelper.ToExtended(path);
|
||||||
|
if (Directory.Exists(extended)) Directory.Delete(extended, recursive: false);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// 目录里还有没搬走的项(例如同名冲突被 Skip 了):保留,不算失败。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Operations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件操作引擎(纯 .NET 实现,不引用任何 WinUI 类型,可独立测试与复用)。
|
||||||
|
///
|
||||||
|
/// 线程模型:
|
||||||
|
/// - 所有作业都在后台 worker 上执行,UI 线程只负责入队/暂停/继续/取消,永不阻塞;
|
||||||
|
/// - 默认串行执行(避免多作业同时读写同一块磁盘造成抖动);
|
||||||
|
/// 同卷 Move / 重命名 / 新建文件夹这类"瞬时元数据操作"走并行车道,不占用串行队首;
|
||||||
|
/// - 进度回调按 50ms 限频,避免 UI 事件风暴。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileOperationService : IFileOperationService, IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>撤销栈上限:超出后丢弃最旧的条目。</summary>
|
||||||
|
private const int MaxUndoEntries = 30;
|
||||||
|
|
||||||
|
/// <summary>进度上报限频(毫秒)。</summary>
|
||||||
|
private const int ProgressFlushIntervalMs = 50;
|
||||||
|
|
||||||
|
/// <summary>JobsChanged 限频(毫秒):进度类变化不按字节风暴式通知。</summary>
|
||||||
|
private const int JobsChangedThrottleMs = 250;
|
||||||
|
|
||||||
|
private const int MaxErrorLength = 2000;
|
||||||
|
|
||||||
|
private readonly JobQueue _queue;
|
||||||
|
private readonly Stack<UndoEntry> _undoStack = new();
|
||||||
|
private readonly object _undoGate = new();
|
||||||
|
private int _lastJobsChangedTick;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public FileOperationService()
|
||||||
|
{
|
||||||
|
_queue = new JobQueue(ExecuteJobAsync);
|
||||||
|
_queue.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 队列与状态
|
||||||
|
|
||||||
|
public IReadOnlyList<FileOperationJob> Jobs => _queue.Jobs;
|
||||||
|
|
||||||
|
public event EventHandler? JobsChanged
|
||||||
|
{
|
||||||
|
add => _queue.JobsChanged += value;
|
||||||
|
remove => _queue.JobsChanged -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>UI 设置冲突回调;未设置时 Ask 策略按 KeepBoth(保留两者)处理。</summary>
|
||||||
|
public Func<ConflictInfo, Task<ConflictResolution>>? ConflictResolver { get; set; }
|
||||||
|
|
||||||
|
public void Pause(Guid jobId) => _queue.Pause(jobId);
|
||||||
|
|
||||||
|
public void Resume(Guid jobId) => _queue.Resume(jobId);
|
||||||
|
|
||||||
|
public void Cancel(Guid jobId) => _queue.Cancel(jobId);
|
||||||
|
|
||||||
|
public void ClearFinished() => _queue.ClearFinished();
|
||||||
|
|
||||||
|
/// <summary>进度类变化按 250ms 限频触发 JobsChanged,避免逐字节通知造成 UI 事件风暴。</summary>
|
||||||
|
private void NotifyJobsChangedThrottled()
|
||||||
|
{
|
||||||
|
var now = Environment.TickCount;
|
||||||
|
if (unchecked(now - _lastJobsChangedTick) < JobsChangedThrottleMs) return;
|
||||||
|
_lastJobsChangedTick = now;
|
||||||
|
_queue.Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 入队
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueCopy(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
|
||||||
|
=> EnqueueTransfer(FileOperationKind.Copy, sources, destination, policy);
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueMove(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
|
||||||
|
=> EnqueueTransfer(FileOperationKind.Move, sources, destination, policy);
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueDelete(IReadOnlyList<string> paths, bool permanent = false)
|
||||||
|
{
|
||||||
|
var list = NormalizePaths(paths);
|
||||||
|
var job = new FileOperationJob
|
||||||
|
{
|
||||||
|
Kind = permanent ? FileOperationKind.Delete : FileOperationKind.Recycle,
|
||||||
|
Title = permanent ? $"永久删除 {list.Count} 个项目" : $"删除 {list.Count} 个项目到回收站",
|
||||||
|
Sources = list,
|
||||||
|
PermanentDelete = permanent,
|
||||||
|
Policy = ConflictPolicy.Replace,
|
||||||
|
// 回收站删除由 Shell 一次调用完成一批,拿不到字节进度,只按文件数上报。
|
||||||
|
IsIndeterminate = !permanent
|
||||||
|
};
|
||||||
|
|
||||||
|
_queue.Enqueue(job, instantLane: false);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueRename(string path, string newName)
|
||||||
|
{
|
||||||
|
var source = PathHelper.TryGetFullPath(path) ?? path;
|
||||||
|
var job = new FileOperationJob
|
||||||
|
{
|
||||||
|
Kind = FileOperationKind.Rename,
|
||||||
|
Title = $"重命名为“{newName}”",
|
||||||
|
Sources = [source],
|
||||||
|
NewName = newName,
|
||||||
|
Policy = ConflictPolicy.Skip,
|
||||||
|
IsIndeterminate = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_queue.Enqueue(job, instantLane: true);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueNewFolder(string parentDirectory, string name)
|
||||||
|
{
|
||||||
|
var parent = PathHelper.TryGetFullPath(parentDirectory) ?? parentDirectory;
|
||||||
|
var job = new FileOperationJob
|
||||||
|
{
|
||||||
|
Kind = FileOperationKind.NewFolder,
|
||||||
|
Title = $"新建文件夹“{name}”",
|
||||||
|
Sources = [parent],
|
||||||
|
Destination = parent,
|
||||||
|
NewName = name,
|
||||||
|
Policy = ConflictPolicy.KeepBoth,
|
||||||
|
IsIndeterminate = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_queue.Enqueue(job, instantLane: true);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileOperationJob EnqueueTransfer(FileOperationKind kind, IReadOnlyList<string> sources, string destination, ConflictPolicy policy)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(sources);
|
||||||
|
ArgumentNullException.ThrowIfNull(destination);
|
||||||
|
|
||||||
|
var list = NormalizePaths(sources);
|
||||||
|
var destinationPath = PathHelper.TryGetFullPath(destination) ?? destination;
|
||||||
|
var verb = kind == FileOperationKind.Copy ? "复制" : "移动";
|
||||||
|
|
||||||
|
var job = new FileOperationJob
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
Title = list.Count == 1
|
||||||
|
? $"{verb}“{PathHelper.GetFileName(list[0])}”到 {destinationPath}"
|
||||||
|
: $"{verb} {list.Count} 个项目到 {destinationPath}",
|
||||||
|
Sources = list,
|
||||||
|
Destination = destinationPath,
|
||||||
|
Policy = policy
|
||||||
|
};
|
||||||
|
|
||||||
|
_queue.Enqueue(job, instantLane: kind == FileOperationKind.Move && IsSameVolumeMove(list, destinationPath));
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> NormalizePaths(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
if (paths is null) return result;
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) continue;
|
||||||
|
var full = PathHelper.TryGetFullPath(path) ?? path.Trim();
|
||||||
|
if (!result.Contains(full, StringComparer.OrdinalIgnoreCase)) result.Add(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 目标路径解析(入队与执行两处必须一致):
|
||||||
|
/// - 目标是已存在的目录 / 末尾带分隔符 / 无扩展名 → 视为"放进该目录";
|
||||||
|
/// - 否则(单个源 + 目标不存在 + 带扩展名)→ 目标即完整目标路径,等价于"复制并改名"。
|
||||||
|
/// </summary>
|
||||||
|
private static List<(string Source, string Target)> ResolveTargets(IReadOnlyList<string> sources, string destination)
|
||||||
|
{
|
||||||
|
var targets = new List<(string Source, string Target)>();
|
||||||
|
if (sources.Count == 0) return targets;
|
||||||
|
|
||||||
|
var treatAsDirectory = sources.Count > 1
|
||||||
|
|| PathHelper.DirectoryExists(destination)
|
||||||
|
|| destination.EndsWith(Path.DirectorySeparatorChar)
|
||||||
|
|| destination.EndsWith(Path.AltDirectorySeparatorChar)
|
||||||
|
|| Path.GetExtension(destination).Length == 0;
|
||||||
|
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
targets.Add(treatAsDirectory
|
||||||
|
? (source, PathHelper.Combine(destination, PathHelper.GetFileName(source)))
|
||||||
|
: (source, destination));
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSameVolumeMove(IReadOnlyList<string> sources, string destination)
|
||||||
|
{
|
||||||
|
if (sources.Count == 0) return false;
|
||||||
|
foreach (var (source, target) in ResolveTargets(sources, destination))
|
||||||
|
{
|
||||||
|
if (!PathHelper.SameVolume(source, target)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 作业执行
|
||||||
|
|
||||||
|
private async Task ExecuteJobAsync(JobContext ctx)
|
||||||
|
{
|
||||||
|
var job = ctx.Job;
|
||||||
|
var runner = new JobRunner(this, ctx);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
job.State = ctx.PauseGate.IsSet ? JobState.Running : JobState.Paused;
|
||||||
|
runner.Notify();
|
||||||
|
|
||||||
|
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!ctx.Cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
switch (job.Kind)
|
||||||
|
{
|
||||||
|
case FileOperationKind.Copy:
|
||||||
|
await RunTransferAsync(runner, isMove: false).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case FileOperationKind.Move:
|
||||||
|
await RunTransferAsync(runner, isMove: true).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case FileOperationKind.Delete:
|
||||||
|
case FileOperationKind.Recycle:
|
||||||
|
await RunDeleteAsync(runner).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case FileOperationKind.Rename:
|
||||||
|
await RunRenameAsync(runner).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case FileOperationKind.NewFolder:
|
||||||
|
await RunNewFolderAsync(runner).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// 取消是正常流程:已完成的部分保留,不回滚。
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
runner.Warn($"作业异常:{ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
runner.Complete();
|
||||||
|
if (runner.UndoEntry is { } entry) PushUndo(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunTransferAsync(JobRunner runner, bool isMove)
|
||||||
|
{
|
||||||
|
var job = runner.Job;
|
||||||
|
var targets = ResolveTargets(job.Sources, job.Destination ?? string.Empty);
|
||||||
|
var sameVolumeMove = isMove && IsSameVolumeMove(job.Sources, job.Destination ?? string.Empty);
|
||||||
|
|
||||||
|
if (sameVolumeMove)
|
||||||
|
{
|
||||||
|
// 同卷 Move 是瞬时元数据操作,不产生字节流量:
|
||||||
|
// 这里刻意不做字节测量,让进度条按"条目数"走,而不是永远停在 0%。
|
||||||
|
job.TotalBytes = 0;
|
||||||
|
job.TotalItems = targets.Count;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (bytes, items) = CopyEngine.Measure(job.Sources, runner.Token, runner.Warn);
|
||||||
|
job.TotalBytes = bytes;
|
||||||
|
job.TotalItems = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.IsIndeterminate = false;
|
||||||
|
runner.Notify();
|
||||||
|
|
||||||
|
foreach (var (source, target) in targets)
|
||||||
|
{
|
||||||
|
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (runner.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
runner.SetCurrentItem(source);
|
||||||
|
|
||||||
|
var outcome = isMove
|
||||||
|
? await CopyEngine.MoveEntryAsync(source, target, runner).ConfigureAwait(false)
|
||||||
|
: await CopyEngine.CopyEntryAsync(source, target, runner).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (outcome == EntryOutcome.Success) runner.CountSucceeded();
|
||||||
|
else if (outcome == EntryOutcome.Cancelled) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMove && runner.MoveRecords.Count > 0)
|
||||||
|
{
|
||||||
|
runner.SetUndoEntry(new UndoEntry
|
||||||
|
{
|
||||||
|
Description = $"撤销 移动 {job.Sources.Count} 个项目到 {job.Destination}",
|
||||||
|
Kind = FileOperationKind.Move,
|
||||||
|
Moves = [.. runner.MoveRecords]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunDeleteAsync(JobRunner runner)
|
||||||
|
{
|
||||||
|
var job = runner.Job;
|
||||||
|
if (job.Sources.Count == 0) return;
|
||||||
|
|
||||||
|
if (job.PermanentDelete)
|
||||||
|
{
|
||||||
|
var (_, deleteItems) = CopyEngine.Measure(job.Sources, runner.Token, runner.Warn);
|
||||||
|
job.TotalBytes = 0;
|
||||||
|
job.TotalItems = deleteItems;
|
||||||
|
job.IsIndeterminate = false;
|
||||||
|
runner.Notify();
|
||||||
|
|
||||||
|
foreach (var path in job.Sources)
|
||||||
|
{
|
||||||
|
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (runner.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
var outcome = await CopyEngine.DeletePermanentAsync(path, runner, countAsItem: true).ConfigureAwait(false);
|
||||||
|
if (outcome == EntryOutcome.Success) runner.CountSucceeded();
|
||||||
|
else if (outcome == EntryOutcome.Cancelled) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 永久删除无法撤销:不产生 UndoEntry(避免 Ctrl+Z 出现"撤销后什么都没发生"的空操作)。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.TotalBytes = 0;
|
||||||
|
job.TotalItems = job.Sources.Count;
|
||||||
|
job.IsIndeterminate = true;
|
||||||
|
runner.Notify();
|
||||||
|
|
||||||
|
// 1) 删除前对每个卷的 <卷>:\$Recycle.Bin\<SID>\ 做 $I 快照(撤销定位靠前后差集)。
|
||||||
|
var before = RecycleBinLocator.CaptureState(job.Sources);
|
||||||
|
|
||||||
|
runner.SetCurrentItem(job.Sources.Count == 1 ? job.Sources[0] : $"{job.Sources.Count} 个项目");
|
||||||
|
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (runner.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
// 2) 一次 Shell 调用完成一批(FOF_ALLOWUNDO = 进回收站而不是永久删除)。
|
||||||
|
var (success, aborted, code) = await RecycleBinLocator.DeleteToRecycleBinAsync(job.Sources).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
var reason = aborted ? "操作被 Shell 中止。" : RecycleBinLocator.DescribeResult(code);
|
||||||
|
runner.Warn($"删除到回收站失败:{reason}");
|
||||||
|
runner.CountFailed(job.Sources.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedCount = job.Sources.Count(p => !PathHelper.Exists(p));
|
||||||
|
job.CompletedItems = deletedCount;
|
||||||
|
runner.CountSucceeded(Math.Max(deletedCount, 0));
|
||||||
|
|
||||||
|
foreach (var path in job.Sources.Where(PathHelper.Exists))
|
||||||
|
runner.Warn($"Shell 报告成功但文件仍然存在:{path}");
|
||||||
|
|
||||||
|
// 3) 差集定位回收站内新增的 $I/$R,填进 UndoEntry.Deleted(解析失败只警告,绝不崩溃)。
|
||||||
|
var (items, diagnostics) = RecycleBinLocator.ResolveDeletedItems(job.Sources, before);
|
||||||
|
foreach (var diagnostic in diagnostics) runner.Warn(diagnostic);
|
||||||
|
|
||||||
|
if (items.Count > 0 && items.All(i => string.IsNullOrEmpty(i.RecyclePath)))
|
||||||
|
{
|
||||||
|
runner.Warn("回收站不可用(Shell 无法在 <卷>:\\$Recycle.Bin 下建立 $I/$R 记录),本次删除实际为永久删除,无法撤销。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.Count > 0)
|
||||||
|
{
|
||||||
|
runner.SetUndoEntry(new UndoEntry
|
||||||
|
{
|
||||||
|
Description = $"撤销 删除 {deletedCount} 个项目",
|
||||||
|
Kind = FileOperationKind.Recycle,
|
||||||
|
Moves = [],
|
||||||
|
Deleted = items
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunRenameAsync(JobRunner runner)
|
||||||
|
{
|
||||||
|
var job = runner.Job;
|
||||||
|
job.TotalBytes = 0;
|
||||||
|
job.TotalItems = 1;
|
||||||
|
job.IsIndeterminate = true;
|
||||||
|
runner.Notify();
|
||||||
|
|
||||||
|
var source = job.Sources.FirstOrDefault();
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
runner.Warn("没有指定要重命名的路径。");
|
||||||
|
runner.CountFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newName = job.NewName ?? string.Empty;
|
||||||
|
if (!PathHelper.TryValidateFileName(newName, out var validationError))
|
||||||
|
{
|
||||||
|
runner.Warn(validationError!);
|
||||||
|
runner.CountFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = PathHelper.Combine(PathHelper.GetDirectoryName(source), newName);
|
||||||
|
|
||||||
|
if (string.Equals(source.TrimEnd('\\'), target.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// 名称没变化:直接算成功,不要动文件。
|
||||||
|
job.CompletedItems = 1;
|
||||||
|
runner.CountSucceeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PathHelper.Exists(target))
|
||||||
|
{
|
||||||
|
runner.Warn($"目标名称已存在,重命名不会覆盖:{target}");
|
||||||
|
runner.CountFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (runner.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
var outcome = await CopyEngine.MoveDirectAsync(source, target, runner).ConfigureAwait(false);
|
||||||
|
if (outcome == EntryOutcome.Success)
|
||||||
|
{
|
||||||
|
job.CompletedItems = 1;
|
||||||
|
runner.CountSucceeded();
|
||||||
|
runner.SetUndoEntry(new UndoEntry
|
||||||
|
{
|
||||||
|
Description = $"撤销 重命名“{newName}”",
|
||||||
|
Kind = FileOperationKind.Rename,
|
||||||
|
Moves = [.. runner.MoveRecords]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (outcome != EntryOutcome.Cancelled)
|
||||||
|
{
|
||||||
|
runner.CountFailed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunNewFolderAsync(JobRunner runner)
|
||||||
|
{
|
||||||
|
var job = runner.Job;
|
||||||
|
job.TotalBytes = 0;
|
||||||
|
job.TotalItems = 1;
|
||||||
|
job.IsIndeterminate = true;
|
||||||
|
runner.Notify();
|
||||||
|
|
||||||
|
var parent = job.Destination ?? job.Sources.FirstOrDefault();
|
||||||
|
if (parent is null)
|
||||||
|
{
|
||||||
|
runner.Warn("没有指定父目录。");
|
||||||
|
runner.CountFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = job.NewName ?? string.Empty;
|
||||||
|
if (!PathHelper.TryValidateFileName(name, out var validationError))
|
||||||
|
{
|
||||||
|
runner.Warn(validationError!);
|
||||||
|
runner.CountFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runner.WaitIfPausedAsync().ConfigureAwait(false);
|
||||||
|
if (runner.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(PathHelper.ToExtended(parent));
|
||||||
|
|
||||||
|
// 重名时自动避让:"新建文件夹 (2)"。
|
||||||
|
var target = PathHelper.MakeUniquePath(PathHelper.Combine(parent, name));
|
||||||
|
Directory.CreateDirectory(PathHelper.ToExtended(target));
|
||||||
|
job.CurrentItem = target;
|
||||||
|
job.CompletedItems = 1;
|
||||||
|
runner.CountSucceeded();
|
||||||
|
runner.Warn($"已创建:{target}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
runner.Warn($"新建文件夹失败:{ex.Message}");
|
||||||
|
runner.CountFailed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 撤销
|
||||||
|
|
||||||
|
public bool CanUndo
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_undoGate) return _undoStack.Count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? UndoDescription
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_undoGate) return _undoStack.Count > 0 ? _undoStack.Peek().Description : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? UndoStackChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一步撤销栈顶作业:Move/Rename 逐项搬回原位置,Recycle 把回收站里的 $R 搬回原始路径。
|
||||||
|
/// 所有 IO 都在线程池上执行(Task.Run + 异步 IO),UI await 即可,绝不会阻塞 UI 线程。
|
||||||
|
/// </summary>
|
||||||
|
public Task<OperationResult> UndoAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.Run(() => UndoCoreAsync(cancellationToken), CancellationToken.None);
|
||||||
|
|
||||||
|
private async Task<OperationResult> UndoCoreAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
UndoEntry? entry;
|
||||||
|
lock (_undoGate)
|
||||||
|
{
|
||||||
|
if (_undoStack.Count == 0) return new OperationResult(false, 0, 0, 0, "没有可撤销的操作。");
|
||||||
|
entry = _undoStack.Pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
UndoStackChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
var sink = new UndoSink(cancellationToken);
|
||||||
|
var succeeded = 0;
|
||||||
|
var failed = 0;
|
||||||
|
|
||||||
|
// 1) Move / Rename:把 From(当前位置)搬回 To(原位置)。同卷时是瞬时 File.Move。
|
||||||
|
foreach (var (from, to) in entry.Moves)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
if (!PathHelper.Exists(from))
|
||||||
|
{
|
||||||
|
sink.Warn($"撤销失败,找不到待搬回的项目:{from}");
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
PathHelper.EnsureParentDirectory(to);
|
||||||
|
|
||||||
|
// 撤销时遇到冲突按 KeepBoth:绝不覆盖用户现有数据。
|
||||||
|
var target = PathHelper.Exists(to) ? PathHelper.MakeUniquePath(to) : to;
|
||||||
|
|
||||||
|
var outcome = await CopyEngine.MoveDirectAsync(from, target, sink).ConfigureAwait(false);
|
||||||
|
if (outcome == EntryOutcome.Success) succeeded++;
|
||||||
|
else failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Recycle:把回收站里的 $R 数据搬回原始路径(目标父目录不存在时先创建)。
|
||||||
|
foreach (var (recyclePath, originalPath) in entry.Deleted)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(recyclePath) || !PathHelper.Exists(recyclePath))
|
||||||
|
{
|
||||||
|
sink.Warn($"无法还原(回收站条目缺失或 $I/$R 解析失败):{originalPath}");
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
PathHelper.EnsureParentDirectory(originalPath);
|
||||||
|
var target = PathHelper.Exists(originalPath) ? PathHelper.MakeUniquePath(originalPath) : originalPath;
|
||||||
|
|
||||||
|
var outcome = await CopyEngine.MoveDirectAsync(recyclePath, target, sink).ConfigureAwait(false);
|
||||||
|
if (outcome == EntryOutcome.Success)
|
||||||
|
{
|
||||||
|
succeeded++;
|
||||||
|
RemoveIndexFile(recyclePath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors = sink.Errors.Count > 0 ? string.Join(Environment.NewLine, sink.Errors) : null;
|
||||||
|
return new OperationResult(failed == 0 && succeeded > 0, succeeded, failed, 0, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>还原成功后顺手删掉对应的 $I 索引,避免回收站里留下指向不存在数据的死条目。</summary>
|
||||||
|
private static void RemoveIndexFile(string recycleDataPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var indexPath = RecycleBinLocator.GetIndexPathFromDataPath(recycleDataPath);
|
||||||
|
if (indexPath is not null && PathHelper.FileExists(indexPath))
|
||||||
|
{
|
||||||
|
PathHelper.ClearReadOnly(indexPath);
|
||||||
|
File.Delete(PathHelper.ToExtended(indexPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// 元数据清理失败不影响还原结果。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PushUndo(UndoEntry entry)
|
||||||
|
{
|
||||||
|
if (entry.Moves.Count == 0 && entry.Deleted.Count == 0) return;
|
||||||
|
|
||||||
|
lock (_undoGate)
|
||||||
|
{
|
||||||
|
_undoStack.Push(entry);
|
||||||
|
|
||||||
|
if (_undoStack.Count > MaxUndoEntries)
|
||||||
|
{
|
||||||
|
// Stack 的枚举顺序是"栈顶在前",取前 N 条即保留最新的 N 条。
|
||||||
|
var kept = _undoStack.Take(MaxUndoEntries).Reverse().ToArray();
|
||||||
|
_undoStack.Clear();
|
||||||
|
foreach (var item in kept) _undoStack.Push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UndoStackChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 测量
|
||||||
|
|
||||||
|
public Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var list = paths is null || paths.Count == 0 ? [] : NormalizePaths(paths);
|
||||||
|
return Task.Run(() => CopyEngine.Measure(list, cancellationToken), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_queue.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 撤销用的空实现 sink
|
||||||
|
|
||||||
|
private sealed class UndoSink : IJobSink
|
||||||
|
{
|
||||||
|
public UndoSink(CancellationToken token) => Token = token;
|
||||||
|
|
||||||
|
public List<string> Errors { get; } = [];
|
||||||
|
|
||||||
|
public CancellationToken Token { get; }
|
||||||
|
|
||||||
|
public bool IsCancellationRequested => Token.IsCancellationRequested;
|
||||||
|
|
||||||
|
public Task WaitIfPausedAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
public void AddBytes(long delta) { }
|
||||||
|
|
||||||
|
public void AddCompletedItems(int delta) { }
|
||||||
|
|
||||||
|
public void SetCurrentItem(string path) { }
|
||||||
|
|
||||||
|
public void Warn(string message) => Errors.Add(message);
|
||||||
|
|
||||||
|
public void AddFailed(string path, string reason) => Errors.Add($"{path}:{reason}");
|
||||||
|
|
||||||
|
public void AddSkipped(int delta = 1) { }
|
||||||
|
|
||||||
|
public void RecordMoveForUndo(string newPath, string originalPath) { }
|
||||||
|
|
||||||
|
public Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info) => Task.FromResult(ConflictResolution.KeepBoth);
|
||||||
|
|
||||||
|
public void RequestCancel() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 单个作业的运行器
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单个作业的执行上下文:负责限频进度上报、统计成功/失败/跳过、收集撤销记录与诊断信息。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class JobRunner : IJobSink
|
||||||
|
{
|
||||||
|
private readonly FileOperationService _owner;
|
||||||
|
private readonly JobContext _ctx;
|
||||||
|
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||||
|
private readonly List<string> _diagnostics = [];
|
||||||
|
private readonly List<(string From, string To)> _moveRecords = [];
|
||||||
|
private long _pendingBytes;
|
||||||
|
private int _pendingItems;
|
||||||
|
private string? _pendingCurrentItem;
|
||||||
|
private long _lastFlushMs;
|
||||||
|
private long _lastBytesFlushMs;
|
||||||
|
private long _lastRaiseAllMs;
|
||||||
|
private double _speed;
|
||||||
|
private int _succeeded;
|
||||||
|
private int _failed;
|
||||||
|
private int _skipped;
|
||||||
|
private ConflictResolution? _applyToAll;
|
||||||
|
|
||||||
|
public JobRunner(FileOperationService owner, JobContext ctx)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_ctx = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileOperationJob Job => _ctx.Job;
|
||||||
|
|
||||||
|
public CancellationToken Token => _ctx.Cts.Token;
|
||||||
|
|
||||||
|
public bool IsCancellationRequested => _ctx.Cts.IsCancellationRequested;
|
||||||
|
|
||||||
|
public List<(string From, string To)> MoveRecords => _moveRecords;
|
||||||
|
|
||||||
|
public UndoEntry? UndoEntry { get; private set; }
|
||||||
|
|
||||||
|
public void SetUndoEntry(UndoEntry entry) => UndoEntry = entry;
|
||||||
|
|
||||||
|
/// <summary>立即刷新一次进度并通知 UI(作业开始、阶段切换、结束等关键时刻)。</summary>
|
||||||
|
public void Notify() => FlushProgress(force: true);
|
||||||
|
|
||||||
|
public async Task WaitIfPausedAsync()
|
||||||
|
{
|
||||||
|
var gate = _ctx.PauseGate;
|
||||||
|
while (!gate.IsSet)
|
||||||
|
{
|
||||||
|
if (IsCancellationRequested) return;
|
||||||
|
await gate.WaitAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddBytes(long delta)
|
||||||
|
{
|
||||||
|
if (delta == 0) return;
|
||||||
|
Interlocked.Add(ref _pendingBytes, delta);
|
||||||
|
FlushIfDue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddCompletedItems(int delta)
|
||||||
|
{
|
||||||
|
if (delta == 0) return;
|
||||||
|
Interlocked.Add(ref _pendingItems, delta);
|
||||||
|
FlushIfDue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCurrentItem(string path)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _pendingCurrentItem, path);
|
||||||
|
FlushIfDue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddSkipped(int delta = 1)
|
||||||
|
{
|
||||||
|
_skipped += delta;
|
||||||
|
AddCompletedItems(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddFailed(string path, string reason)
|
||||||
|
{
|
||||||
|
_failed++;
|
||||||
|
Warn($"{path}:{reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Warn(string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message)) return;
|
||||||
|
_diagnostics.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CountSucceeded(int delta = 1) => _succeeded += delta;
|
||||||
|
|
||||||
|
public void CountFailed(int delta = 1) => _failed += delta;
|
||||||
|
|
||||||
|
public void RecordMoveForUndo(string newPath, string originalPath) => _moveRecords.Add((newPath, originalPath));
|
||||||
|
|
||||||
|
public void RequestCancel()
|
||||||
|
{
|
||||||
|
Warn("已按用户选择取消后续操作。");
|
||||||
|
_ctx.Cts.Cancel();
|
||||||
|
_ctx.PauseGate.Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info)
|
||||||
|
{
|
||||||
|
// "为后续所有冲突执行相同操作":一次勾选,后续冲突不再打扰 UI。
|
||||||
|
if (_applyToAll is { } applied) return applied;
|
||||||
|
|
||||||
|
var resolver = _owner.ConflictResolver;
|
||||||
|
if (Job.Policy == ConflictPolicy.Ask && resolver is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 回调是 await 的:期间作业状态保持 Running(或 Paused),当前项挂起,
|
||||||
|
// 但 UI 线程完全自由(回调由 UI 自己 marshal 回 UI 线程弹对话框)。
|
||||||
|
var resolution = await resolver(info).ConfigureAwait(false);
|
||||||
|
if (info.ApplyToAll) _applyToAll = resolution;
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Warn($"冲突回调异常,按“保留两者”处理:{ex.Message}");
|
||||||
|
return ConflictResolution.KeepBoth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Job.Policy switch
|
||||||
|
{
|
||||||
|
ConflictPolicy.Replace or ConflictPolicy.Merge => ConflictResolution.Replace,
|
||||||
|
ConflictPolicy.Skip => ConflictResolution.Skip,
|
||||||
|
_ => ConflictResolution.KeepBoth // Ask 但没有 UI 回调 → 保留两者
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushIfDue()
|
||||||
|
{
|
||||||
|
if (_clock.ElapsedMilliseconds - _lastFlushMs >= ProgressFlushIntervalMs) FlushProgress(force: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 限频进度刷新:
|
||||||
|
/// - 数值属性(字节 / 条目 / 当前项 / 速度)每 50ms 写一次,各自只触发一个 PropertyChanged;
|
||||||
|
/// - 计算属性(Progress / Eta / CanPause…)每 250ms 通过一次 RaiseAll 刷新。
|
||||||
|
/// 于是 2000 个文件的复制只产生约 20 次/秒的 UI 通知,而不是"每个文件一次"的事件风暴。
|
||||||
|
/// </summary>
|
||||||
|
private void FlushProgress(bool force)
|
||||||
|
{
|
||||||
|
var now = _clock.ElapsedMilliseconds;
|
||||||
|
var bytes = Interlocked.Exchange(ref _pendingBytes, 0);
|
||||||
|
var items = Interlocked.Exchange(ref _pendingItems, 0);
|
||||||
|
var current = Interlocked.Exchange(ref _pendingCurrentItem, null);
|
||||||
|
|
||||||
|
if (bytes != 0)
|
||||||
|
{
|
||||||
|
Job.CompletedBytes += bytes;
|
||||||
|
|
||||||
|
var elapsed = Math.Max(1, now - _lastBytesFlushMs);
|
||||||
|
var instant = bytes * 1000.0 / elapsed;
|
||||||
|
_speed = _speed <= 1 ? instant : (_speed * 0.7) + (instant * 0.3);
|
||||||
|
Job.BytesPerSecond = _speed;
|
||||||
|
_lastBytesFlushMs = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items != 0) Job.CompletedItems += items;
|
||||||
|
if (current is not null) Job.CurrentItem = current;
|
||||||
|
|
||||||
|
_lastFlushMs = now;
|
||||||
|
|
||||||
|
if (force || now - _lastRaiseAllMs >= JobsChangedThrottleMs)
|
||||||
|
{
|
||||||
|
_lastRaiseAllMs = now;
|
||||||
|
// RaiseAll 让绑定 Progress / Eta 的 UI 也能刷新(普通 Set 只通知单个属性)。
|
||||||
|
Job.RaiseAll();
|
||||||
|
_owner.NotifyJobsChangedThrottled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Complete()
|
||||||
|
{
|
||||||
|
FlushProgress(force: true);
|
||||||
|
|
||||||
|
var job = Job;
|
||||||
|
var cancelled = IsCancellationRequested;
|
||||||
|
|
||||||
|
if (cancelled)
|
||||||
|
{
|
||||||
|
var bytes = job.TotalBytes > 0
|
||||||
|
? $",{FormatBytes(job.CompletedBytes)}/{FormatBytes(job.TotalBytes)}"
|
||||||
|
: string.Empty;
|
||||||
|
_diagnostics.Insert(0, $"已取消,已完成 {job.CompletedItems}/{job.TotalItems} 项{bytes}。");
|
||||||
|
job.State = JobState.Cancelled;
|
||||||
|
}
|
||||||
|
else if (_failed > 0)
|
||||||
|
{
|
||||||
|
job.State = _succeeded > 0 ? JobState.CompletedWithErrors : JobState.Failed;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
job.State = JobState.Completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_skipped > 0) _diagnostics.Add($"已跳过 {_skipped} 个项目。");
|
||||||
|
job.Error = BuildDiagnostics();
|
||||||
|
|
||||||
|
Job.RaiseAll();
|
||||||
|
_owner._queue.Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? BuildDiagnostics()
|
||||||
|
{
|
||||||
|
if (_diagnostics.Count == 0) return null;
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
var included = 0;
|
||||||
|
foreach (var line in _diagnostics)
|
||||||
|
{
|
||||||
|
if (builder.Length + line.Length + 1 > MaxErrorLength)
|
||||||
|
{
|
||||||
|
builder.Append(Environment.NewLine).Append($"…(其余 {_diagnostics.Count - included} 条已省略)");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (builder.Length > 0) builder.Append(Environment.NewLine);
|
||||||
|
builder.Append(line);
|
||||||
|
included++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatBytes(long bytes)
|
||||||
|
{
|
||||||
|
string[] units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
double value = bytes;
|
||||||
|
var unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.Length - 1)
|
||||||
|
{
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{value:0.##}{units[unit]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Operations;
|
||||||
|
|
||||||
|
public enum FileOperationKind
|
||||||
|
{
|
||||||
|
Copy,
|
||||||
|
Move,
|
||||||
|
Delete,
|
||||||
|
Recycle,
|
||||||
|
Rename,
|
||||||
|
NewFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum JobState
|
||||||
|
{
|
||||||
|
Queued,
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Completed,
|
||||||
|
CompletedWithErrors,
|
||||||
|
Cancelled,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ConflictPolicy
|
||||||
|
{
|
||||||
|
/// <summary>交给 UI 决定(ConflictResolver)。</summary>
|
||||||
|
Ask,
|
||||||
|
Replace,
|
||||||
|
Skip,
|
||||||
|
KeepBoth,
|
||||||
|
Merge
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ConflictResolution
|
||||||
|
{
|
||||||
|
Replace,
|
||||||
|
Skip,
|
||||||
|
KeepBoth,
|
||||||
|
Cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConflictInfo
|
||||||
|
{
|
||||||
|
public required string SourcePath { get; init; }
|
||||||
|
public required string DestinationPath { get; init; }
|
||||||
|
public bool SourceIsDirectory { get; init; }
|
||||||
|
public long SourceSize { get; init; }
|
||||||
|
public long DestinationSize { get; init; }
|
||||||
|
public DateTime SourceModifiedUtc { get; init; }
|
||||||
|
public DateTime DestinationModifiedUtc { get; init; }
|
||||||
|
/// <summary>true 表示用户勾选了"为后续所有冲突执行相同操作"。</summary>
|
||||||
|
public bool ApplyToAll { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>队列里的一个作业:可暂停/继续/取消,进度实时上报(含速度与剩余时间)。</summary>
|
||||||
|
public sealed class FileOperationJob : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private JobState _state = JobState.Queued;
|
||||||
|
private int _completedItems;
|
||||||
|
private long _completedBytes;
|
||||||
|
private string? _currentItem;
|
||||||
|
private string? _error;
|
||||||
|
private double _bytesPerSecond;
|
||||||
|
private bool _isIndeterminate;
|
||||||
|
|
||||||
|
public Guid Id { get; } = Guid.NewGuid();
|
||||||
|
public required FileOperationKind Kind { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required IReadOnlyList<string> Sources { get; init; }
|
||||||
|
public string? Destination { get; init; }
|
||||||
|
public ConflictPolicy Policy { get; init; } = ConflictPolicy.Ask;
|
||||||
|
public bool PermanentDelete { get; init; }
|
||||||
|
public string? NewName { get; init; }
|
||||||
|
public DateTime StartedUtc { get; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public JobState State
|
||||||
|
{
|
||||||
|
get => _state;
|
||||||
|
set => Set(ref _state, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int TotalItems { get; set; }
|
||||||
|
public int CompletedItems { get => _completedItems; set => Set(ref _completedItems, value); }
|
||||||
|
public long TotalBytes { get; set; }
|
||||||
|
public long CompletedBytes { get => _completedBytes; set => Set(ref _completedBytes, value); }
|
||||||
|
public double BytesPerSecond { get => _bytesPerSecond; set => Set(ref _bytesPerSecond, value); }
|
||||||
|
public string? CurrentItem { get => _currentItem; set => Set(ref _currentItem, value); }
|
||||||
|
public string? Error { get => _error; set => Set(ref _error, value); }
|
||||||
|
|
||||||
|
/// <summary>大小为 0 的作业(纯重命名等)显示旋转指示器。</summary>
|
||||||
|
public bool IsIndeterminate { get => _isIndeterminate; set => Set(ref _isIndeterminate, value); }
|
||||||
|
|
||||||
|
public double Progress => TotalBytes > 0
|
||||||
|
? Math.Clamp((double)CompletedBytes / TotalBytes, 0, 1)
|
||||||
|
: (TotalItems > 0 ? Math.Clamp((double)CompletedItems / TotalItems, 0, 1) : 0);
|
||||||
|
|
||||||
|
public TimeSpan? Eta => BytesPerSecond > 1 && TotalBytes > CompletedBytes
|
||||||
|
? TimeSpan.FromSeconds((TotalBytes - CompletedBytes) / BytesPerSecond)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public bool CanPause => State is JobState.Running or JobState.Paused;
|
||||||
|
public bool CanCancel => State is JobState.Queued or JobState.Running or JobState.Paused;
|
||||||
|
public bool IsFinished => State is JobState.Completed or JobState.CompletedWithErrors or JobState.Cancelled or JobState.Failed;
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
internal void RaiseAll()
|
||||||
|
{
|
||||||
|
foreach (var name in new[] { nameof(State), nameof(CompletedItems), nameof(CompletedBytes), nameof(BytesPerSecond),
|
||||||
|
nameof(CurrentItem), nameof(Error), nameof(Progress), nameof(Eta),
|
||||||
|
nameof(CanPause), nameof(CanCancel), nameof(IsFinished), nameof(IsIndeterminate) })
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||||
|
{
|
||||||
|
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||||
|
field = value;
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UndoEntry
|
||||||
|
{
|
||||||
|
public required string Description { get; init; }
|
||||||
|
public required FileOperationKind Kind { get; init; }
|
||||||
|
/// <summary>(原路径, 现路径) 对;撤销即反向搬运。</summary>
|
||||||
|
public required List<(string From, string To)> Moves { get; init; }
|
||||||
|
/// <summary>删除操作记录:回收站里的 $R 文件路径 → 原始路径。</summary>
|
||||||
|
public List<(string RecyclePath, string OriginalPath)> Deleted { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record OperationResult(bool Success, int Succeeded, int Failed, int Skipped, string? Error = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件操作引擎:所有操作进入统一队列串行/并行执行,UI 永不阻塞。
|
||||||
|
/// 支持暂停、继续、取消、冲突策略、错误重试,以及一步撤销(Ctrl+Z)。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFileOperationService
|
||||||
|
{
|
||||||
|
IReadOnlyList<FileOperationJob> Jobs { get; }
|
||||||
|
event EventHandler? JobsChanged;
|
||||||
|
|
||||||
|
/// <summary>UI 设置此回调以弹出冲突对话框;未设置时按 KeepBoth 处理。</summary>
|
||||||
|
Func<ConflictInfo, Task<ConflictResolution>>? ConflictResolver { get; set; }
|
||||||
|
|
||||||
|
FileOperationJob EnqueueCopy(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask);
|
||||||
|
FileOperationJob EnqueueMove(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask);
|
||||||
|
FileOperationJob EnqueueDelete(IReadOnlyList<string> paths, bool permanent = false);
|
||||||
|
FileOperationJob EnqueueRename(string path, string newName);
|
||||||
|
FileOperationJob EnqueueNewFolder(string parentDirectory, string name);
|
||||||
|
|
||||||
|
void Pause(Guid jobId);
|
||||||
|
void Resume(Guid jobId);
|
||||||
|
void Cancel(Guid jobId);
|
||||||
|
void ClearFinished();
|
||||||
|
|
||||||
|
bool CanUndo { get; }
|
||||||
|
string? UndoDescription { get; }
|
||||||
|
event EventHandler? UndoStackChanged;
|
||||||
|
Task<OperationResult> UndoAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>计算源集合的总大小与条目数(后台执行,用于进度条与冲突提示)。</summary>
|
||||||
|
Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Operations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 可异步等待的自动/手动复位事件:用于"暂停"语义。
|
||||||
|
/// 关键点:<see cref="TaskCompletionSource"/> 必须带 RunContinuationsAsynchronously,
|
||||||
|
/// 否则 <see cref="Set"/> 会在调用线程(通常是 UI 线程)上同步执行拷贝循环的续体,
|
||||||
|
/// 从而把磁盘 IO 拖回 UI 线程。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AsyncManualResetEvent
|
||||||
|
{
|
||||||
|
private TaskCompletionSource _tcs;
|
||||||
|
|
||||||
|
public AsyncManualResetEvent(bool initialState = true) => _tcs = Create(initialState);
|
||||||
|
|
||||||
|
public bool IsSet => _tcs.Task.IsCompleted;
|
||||||
|
|
||||||
|
public Task WaitAsync() => _tcs.Task;
|
||||||
|
|
||||||
|
public void Set() => _tcs.TrySetResult();
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var current = _tcs;
|
||||||
|
if (!current.Task.IsCompleted) return;
|
||||||
|
var fresh = Create(false);
|
||||||
|
if (ReferenceEquals(Interlocked.CompareExchange(ref _tcs, fresh, current), current)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskCompletionSource Create(bool completed)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
if (completed) tcs.SetResult();
|
||||||
|
return tcs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>引擎内部对一个作业的控制块:取消令牌 + 暂停闸门 + 调度车道标记。</summary>
|
||||||
|
internal sealed class JobContext
|
||||||
|
{
|
||||||
|
public JobContext(FileOperationJob job) => Job = job;
|
||||||
|
|
||||||
|
public FileOperationJob Job { get; }
|
||||||
|
|
||||||
|
public CancellationTokenSource Cts { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>初始为 Set(未暂停)。Reset 即暂停,Set 即继续。</summary>
|
||||||
|
public AsyncManualResetEvent PauseGate { get; } = new(initialState: true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// true = 走"瞬时车道":同卷 Move / 重命名 / 新建文件夹这类不搬字节的操作,
|
||||||
|
/// 可以和其他作业并行,不必排队等大拷贝。
|
||||||
|
/// </summary>
|
||||||
|
public bool InstantLane { get; set; }
|
||||||
|
|
||||||
|
public bool Started { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 作业队列:后台 worker 严格按入队顺序调度。
|
||||||
|
/// 默认串行(避免多任务同时读盘造成磁盘抖动、拖慢整体吞吐),
|
||||||
|
/// 但"瞬时操作"(同卷 Move / 重命名 / 新建文件夹)允许并行,因为它们只做元数据操作。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class JobQueue : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>瞬时车道最大并行度,防止一次排入上千个瞬时作业时线程爆炸。</summary>
|
||||||
|
private const int MaxInstantParallelism = 4;
|
||||||
|
|
||||||
|
private readonly Func<JobContext, Task> _executor;
|
||||||
|
private readonly ObservableCollection<FileOperationJob> _jobs = [];
|
||||||
|
private readonly List<JobContext> _pending = [];
|
||||||
|
private readonly Dictionary<Guid, JobContext> _contexts = [];
|
||||||
|
private readonly HashSet<Task> _instantTasks = [];
|
||||||
|
private readonly SemaphoreSlim _signal = new(0);
|
||||||
|
private readonly CancellationTokenSource _shutdown = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private Task? _dispatcher;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public JobQueue(Func<JobContext, Task> executor) => _executor = executor;
|
||||||
|
|
||||||
|
public ObservableCollection<FileOperationJob> Jobs => _jobs;
|
||||||
|
|
||||||
|
public event EventHandler? JobsChanged;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_dispatcher ??= Task.Run(DispatcherLoopAsync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Enqueue(FileOperationJob job, bool instantLane)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
var ctx = new JobContext(job) { InstantLane = instantLane };
|
||||||
|
_contexts[job.Id] = ctx;
|
||||||
|
_pending.Add(ctx);
|
||||||
|
_jobs.Add(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
_signal.Release();
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Pause(Guid jobId)
|
||||||
|
{
|
||||||
|
JobContext? ctx;
|
||||||
|
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||||
|
if (ctx is null) return;
|
||||||
|
|
||||||
|
// 只有已经在跑的作业才能暂停(Queued 状态的作业由模型定义为 CanPause=false)。
|
||||||
|
if (!ctx.Job.CanPause) return;
|
||||||
|
|
||||||
|
ctx.PauseGate.Reset();
|
||||||
|
if (ctx.Job.State == JobState.Running) ctx.Job.State = JobState.Paused;
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Resume(Guid jobId)
|
||||||
|
{
|
||||||
|
JobContext? ctx;
|
||||||
|
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||||
|
if (ctx is null || ctx.Job.IsFinished) return;
|
||||||
|
|
||||||
|
ctx.PauseGate.Set();
|
||||||
|
if (ctx.Job.State == JobState.Paused) ctx.Job.State = JobState.Running;
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel(Guid jobId)
|
||||||
|
{
|
||||||
|
JobContext? ctx;
|
||||||
|
lock (_gate) _contexts.TryGetValue(jobId, out ctx);
|
||||||
|
if (ctx is null || ctx.Job.IsFinished) return;
|
||||||
|
|
||||||
|
ctx.Cts.Cancel();
|
||||||
|
|
||||||
|
// 让处于暂停中的拷贝循环立刻被唤醒,从而在同一粒度内观察到取消。
|
||||||
|
ctx.PauseGate.Set();
|
||||||
|
|
||||||
|
if (ctx.Job.State == JobState.Queued)
|
||||||
|
{
|
||||||
|
// 还没开始跑:直接落终态,调度循环只挑 Queued 的作业,因此会自然跳过。
|
||||||
|
ctx.Job.State = JobState.Cancelled;
|
||||||
|
ctx.Job.Error = AppendLine(ctx.Job.Error, "已取消(尚未开始)。");
|
||||||
|
}
|
||||||
|
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearFinished()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
for (var i = _jobs.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (!_jobs[i].IsFinished) continue;
|
||||||
|
_contexts.Remove(_jobs[i].Id);
|
||||||
|
_jobs.RemoveAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pending.RemoveAll(c => c.Job.IsFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Raise() => JobsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
internal static string AppendLine(string? existing, string line)
|
||||||
|
=> string.IsNullOrEmpty(existing) ? line : existing + Environment.NewLine + line;
|
||||||
|
|
||||||
|
private async Task DispatcherLoopAsync()
|
||||||
|
{
|
||||||
|
var token = _shutdown.Token;
|
||||||
|
while (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _signal.WaitAsync(token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
JobContext? ctx;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
ctx = _pending.FirstOrDefault(c => c.Job.State == JobState.Queued);
|
||||||
|
if (ctx is not null && ctx.InstantLane && _instantTasks.Count >= MaxInstantParallelism)
|
||||||
|
ctx = null; // 车道满了:本轮不挑,等下一轮(等价于退化成串行)
|
||||||
|
if (ctx is not null) ctx.Started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx is null) break;
|
||||||
|
|
||||||
|
if (ctx.InstantLane)
|
||||||
|
{
|
||||||
|
var task = Task.Run(() => RunSafelyAsync(ctx), CancellationToken.None);
|
||||||
|
lock (_gate) _instantTasks.Add(task);
|
||||||
|
_ = task.ContinueWith(
|
||||||
|
t =>
|
||||||
|
{
|
||||||
|
lock (_gate) _instantTasks.Remove(t);
|
||||||
|
_signal.Release(); // 唤醒调度循环,检查是否还有排队的作业
|
||||||
|
},
|
||||||
|
CancellationToken.None,
|
||||||
|
TaskContinuationOptions.ExecuteSynchronously,
|
||||||
|
TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await RunSafelyAsync(ctx).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate) _pending.Remove(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunSafelyAsync(JobContext ctx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _executor(ctx).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
if (!ctx.Job.IsFinished) ctx.Job.State = JobState.Cancelled;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ctx.Job.Error = AppendLine(ctx.Job.Error, ex.Message);
|
||||||
|
if (!ctx.Job.IsFinished) ctx.Job.State = JobState.Failed;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Raise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_shutdown.Cancel();
|
||||||
|
try { _signal.Release(); } catch (Exception) { /* 忽略 */ }
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
foreach (var ctx in _contexts.Values)
|
||||||
|
{
|
||||||
|
try { ctx.Cts.Cancel(); } catch (Exception) { /* 忽略 */ }
|
||||||
|
ctx.Cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_contexts.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_signal.Dispose();
|
||||||
|
_shutdown.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Operations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 路径与文件系统元数据工具。
|
||||||
|
///
|
||||||
|
/// 设计约定(整个 Operations 引擎统一遵守):
|
||||||
|
/// 1. 引擎内部、作业描述、UndoEntry 里保存的都是"普通路径"(不带 \\?\ 前缀),
|
||||||
|
/// 只有真正触达 BCL / Win32 IO 的那一刻才通过 <see cref="ToExtended"/> 转换,
|
||||||
|
/// 避免 \\?\ 前缀泄漏到 UI 显示、$I 解析、Shell API 调用里(Shell API 不接受前缀)。
|
||||||
|
/// 2. 所有拼接都用 <see cref="Combine"/>(手工拼分隔符),不用 Path.Combine 后直接丢给 API,
|
||||||
|
/// 以保证超长路径(>260)在所有环节都能正确走到 \\?\ 分支。
|
||||||
|
/// 3. 所有 IO 调用一律走 <see cref="ToExtended"/>,NET8 虽然自身也会兜底长路径,
|
||||||
|
/// 但统一处理后行为可预期(尤其是 UNC:\\server\share → \\?\UNC\server\share)。
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class PathHelper
|
||||||
|
{
|
||||||
|
internal const string ExtendedPrefix = @"\\?\";
|
||||||
|
internal const string ExtendedUncPrefix = @"\\?\UNC\";
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(.*) \((\d+)\)$", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex CopySuffixRegex();
|
||||||
|
|
||||||
|
/// <summary>安全取全路径;非法路径(空、含非法字符、超长到无法规范化)返回 null 而不抛。</summary>
|
||||||
|
internal static string? TryGetFullPath(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(path);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>转成 Win32 长路径形式(\\?\ 或 \\?\UNC\)。已是前缀形式则原样返回。</summary>
|
||||||
|
internal static string ToExtended(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return path;
|
||||||
|
|
||||||
|
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) ||
|
||||||
|
path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||||
|
return path;
|
||||||
|
|
||||||
|
string full;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
full = Path.GetFullPath(path);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// 无法规范化:原样返回,让后续 API 抛出可读异常,由重试/失败统计兜住。
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) ||
|
||||||
|
full.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||||
|
return full;
|
||||||
|
|
||||||
|
// UNC:\\server\share\x → \\?\UNC\server\share\x
|
||||||
|
if (full.StartsWith(@"\\", StringComparison.Ordinal))
|
||||||
|
return ExtendedUncPrefix + full[2..];
|
||||||
|
|
||||||
|
return ExtendedPrefix + full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>去掉长路径前缀,得到可显示/可交给 Shell API 的普通路径。</summary>
|
||||||
|
internal static string StripExtended(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return path;
|
||||||
|
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal))
|
||||||
|
return @"\\" + path[ExtendedUncPrefix.Length..];
|
||||||
|
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||||
|
return path[ExtendedPrefix.Length..];
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>手工拼接子路径(不依赖 Path.Combine 的根路径语义)。</summary>
|
||||||
|
internal static string Combine(string directory, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(directory)) return name;
|
||||||
|
var last = directory[^1];
|
||||||
|
return last is '\\' or '/' ? directory + name : directory + Path.DirectorySeparatorChar + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取最后一段名字(同时兼容带/不带 \\?\ 前缀)。</summary>
|
||||||
|
internal static string GetFileName(string path)
|
||||||
|
{
|
||||||
|
var p = StripExtended(path);
|
||||||
|
if (string.IsNullOrEmpty(p)) return p;
|
||||||
|
var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
if (trimmed.Length == 0) return p; // 卷根,如 "E:\"
|
||||||
|
var idx = trimmed.LastIndexOfAny(['\\', '/']);
|
||||||
|
return idx < 0 ? trimmed : trimmed[(idx + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取父目录;已是卷根时返回卷根本身(不返回 null,方便调用方继续拼接)。</summary>
|
||||||
|
internal static string GetDirectoryName(string path)
|
||||||
|
{
|
||||||
|
var p = StripExtended(path);
|
||||||
|
var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var idx = trimmed.LastIndexOfAny(['\\', '/']);
|
||||||
|
if (idx < 0) return p;
|
||||||
|
var parent = trimmed[..idx];
|
||||||
|
// "E:" → "E:\"
|
||||||
|
if (parent.Length == 2 && parent[1] == ':') return parent + Path.DirectorySeparatorChar;
|
||||||
|
if (parent.Length == 0) return @"\";
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取卷根(用于同卷判定);UNC 时返回 \\server\share\。</summary>
|
||||||
|
internal static string GetVolumeRoot(string path)
|
||||||
|
{
|
||||||
|
var full = TryGetFullPath(path) ?? StripExtended(path);
|
||||||
|
var root = Path.GetPathRoot(full);
|
||||||
|
return string.IsNullOrEmpty(root) ? full : root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>是否同一卷(同卷 Move 才能走瞬时的 File.Move/Directory.Move)。</summary>
|
||||||
|
internal static bool SameVolume(string a, string b)
|
||||||
|
=> string.Equals(GetVolumeRoot(a), GetVolumeRoot(b), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
internal static bool FileExists(string path)
|
||||||
|
{
|
||||||
|
try { return File.Exists(ToExtended(path)); } catch (Exception) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool DirectoryExists(string path)
|
||||||
|
{
|
||||||
|
try { return Directory.Exists(ToExtended(path)); } catch (Exception) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool Exists(string path) => FileExists(path) || DirectoryExists(path);
|
||||||
|
|
||||||
|
internal static FileAttributes? TryGetAttributes(string path)
|
||||||
|
{
|
||||||
|
try { return File.GetAttributes(ToExtended(path)); }
|
||||||
|
catch (Exception) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsDirectory(string path)
|
||||||
|
=> (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.Directory) != 0;
|
||||||
|
|
||||||
|
internal static bool IsReparsePoint(string path)
|
||||||
|
=> (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.ReparsePoint) != 0;
|
||||||
|
|
||||||
|
internal static long TryGetLength(string path)
|
||||||
|
{
|
||||||
|
try { return new FileInfo(ToExtended(path)).Length; }
|
||||||
|
catch (Exception) { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>清掉只读属性,否则覆盖/删除会抛 UnauthorizedAccessException。</summary>
|
||||||
|
internal static void ClearReadOnly(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attrs = File.GetAttributes(ToExtended(path));
|
||||||
|
if ((attrs & FileAttributes.ReadOnly) != 0)
|
||||||
|
File.SetAttributes(ToExtended(path), attrs & ~FileAttributes.ReadOnly);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// 不存在或无权访问:交给真正的操作去抛错,这里不吞掉信息。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>确保父目录存在(撤销时目标父目录可能已被删掉)。</summary>
|
||||||
|
internal static void EnsureParentDirectory(string path)
|
||||||
|
{
|
||||||
|
var parent = GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(ToExtended(parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成 "名字 (2).ext" 风格的不冲突路径;若本身已带 " (n)" 后缀,先剥离再递增,
|
||||||
|
/// 避免出现 "a (2) (2).txt" 这种叠加命名。
|
||||||
|
/// </summary>
|
||||||
|
internal static string MakeUniquePath(string desiredPath)
|
||||||
|
{
|
||||||
|
if (!Exists(desiredPath)) return desiredPath;
|
||||||
|
|
||||||
|
var directory = GetDirectoryName(desiredPath);
|
||||||
|
var name = GetFileName(desiredPath);
|
||||||
|
var ext = Path.GetExtension(name);
|
||||||
|
var stem = ext.Length > 0 ? name[..^ext.Length] : name;
|
||||||
|
|
||||||
|
var m = CopySuffixRegex().Match(stem);
|
||||||
|
if (m.Success) stem = m.Groups[1].Value;
|
||||||
|
|
||||||
|
for (var i = 2; i < 100_000; i++)
|
||||||
|
{
|
||||||
|
var candidate = Combine(directory, $"{stem} ({i}){ext}");
|
||||||
|
if (!Exists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IOException($"无法为“{desiredPath}”生成不冲突的新名称。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>校验文件名(重命名/新建文件夹用),错误信息为中文。</summary>
|
||||||
|
internal static bool TryValidateFileName(string? name, out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
error = "名称不能为空。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Length > 255)
|
||||||
|
{
|
||||||
|
error = "名称过长(最多 255 个字符)。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
if (name.IndexOfAny(invalid) >= 0)
|
||||||
|
{
|
||||||
|
var bad = new string(name.Where(c => Array.IndexOf(invalid, c) >= 0).Distinct().ToArray());
|
||||||
|
error = $"名称包含非法字符:{bad}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.EndsWith(' ') || name.EndsWith('.'))
|
||||||
|
{
|
||||||
|
error = "名称不能以空格或点结尾。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.TrimEnd(' ', '.').Length == 0)
|
||||||
|
{
|
||||||
|
error = "名称不能只由空格或点组成。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stem = Path.GetFileNameWithoutExtension(name);
|
||||||
|
if (IsReservedDeviceName(stem))
|
||||||
|
{
|
||||||
|
error = $"“{stem}”是 Windows 保留设备名,不能用作文件名。";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReservedDeviceName(string stem)
|
||||||
|
{
|
||||||
|
if (stem.Length is 3 or 4)
|
||||||
|
{
|
||||||
|
if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
stem.Equals("NUL", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (stem.Length == 4 && stem[3] is >= '1' and <= '9')
|
||||||
|
{
|
||||||
|
var head = stem[..3];
|
||||||
|
if (head.Equals("COM", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
head.Equals("LPT", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安全枚举某个目录的直接子项(返回普通路径)。
|
||||||
|
/// 单个子目录无权限/枚举中途出错时返回已拿到的部分并回调警告,绝不抛出。
|
||||||
|
/// </summary>
|
||||||
|
internal static List<string> EnumerateChildrenSafe(string directory, Action<string>? onWarning = null)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
IEnumerator<string>? enumerator = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
enumerator = Directory.EnumerateFileSystemEntries(ToExtended(directory)).GetEnumerator();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
|
||||||
|
{
|
||||||
|
onWarning?.Invoke($"无法枚举目录“{directory}”:{ex.Message}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
string current;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!enumerator.MoveNext()) break;
|
||||||
|
current = enumerator.Current;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
onWarning?.Invoke($"枚举目录“{directory}”时中断:{ex.Message}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(StripExtended(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
enumerator.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把 \\?\ 前缀路径还原成普通路径(供 P/Invoke Shell API 使用)。</summary>
|
||||||
|
internal static string ToShellPath(string path) => StripExtended(path);
|
||||||
|
|
||||||
|
/// <summary>判断 extended 前缀是否已存在(调试用)。</summary>
|
||||||
|
internal static bool HasExtendedPrefix(string path)
|
||||||
|
=> path.StartsWith(ExtendedPrefix, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>分配 UTF-16 双 null 结尾路径列表(SHFileOperationW 要求)。</summary>
|
||||||
|
internal static IntPtr AllocDoubleNullList(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
var joined = string.Join('\0', paths) + "\0\0";
|
||||||
|
return Marshal.StringToHGlobalUni(joined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Principal;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Operations;
|
||||||
|
|
||||||
|
/// <summary>回收站里一条 $I 索引记录解析出来的信息。</summary>
|
||||||
|
internal sealed record RecycleBinItem(string IndexPath, string DataPath, string OriginalPath, long Size, DateTime DeletedUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 回收站定位器:
|
||||||
|
/// 1. 用 shell32!SHFileOperationW(FO_DELETE + FOF_ALLOWUNDO) 把一批路径送进回收站(一次调用完成一批);
|
||||||
|
/// 2. 通过"操作前后 <卷>:\$Recycle.Bin\<SID>\ 目录里 $I* 文件的差集"定位本次新增的回收站条目,
|
||||||
|
/// 解析 $I 结构拿到原始路径,并把 $I 前缀换成 $R 得到回收站内的真实数据路径,
|
||||||
|
/// 从而支持 Ctrl+Z 一步还原。
|
||||||
|
///
|
||||||
|
/// $I 文件结构(Win10+ 为版本 2):
|
||||||
|
/// offset 0 8B 版本号(Win10+ = 2)
|
||||||
|
/// offset 8 8B 原始文件大小
|
||||||
|
/// offset 16 8B 删除时间(FILETIME)
|
||||||
|
/// offset 24 4B 文件名长度(仅版本 >= 2 存在)
|
||||||
|
/// offset 24/28 UTF-16LE 的原始完整路径,以 \0 结尾
|
||||||
|
/// 路径长度字段在不同 Windows 版本上语义有歧义(字符数 / 字节数两种实现都有),
|
||||||
|
/// 因此这里直接读到缓冲区末尾并按第一个 \0 截断,比依赖该字段更稳。
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class RecycleBinLocator
|
||||||
|
{
|
||||||
|
private const uint FO_DELETE = 0x0003;
|
||||||
|
private const ushort FOF_SILENT = 0x0004;
|
||||||
|
private const ushort FOF_NOCONFIRMATION = 0x0010;
|
||||||
|
private const ushort FOF_ALLOWUNDO = 0x0040;
|
||||||
|
private const ushort FOF_NOERRORUI = 0x0400;
|
||||||
|
private const ushort FOF_WANTNUKEWARNING = 0x4000;
|
||||||
|
|
||||||
|
private const ushort DeleteFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI | FOF_WANTNUKEWARNING;
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct SHFILEOPSTRUCTW
|
||||||
|
{
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public uint wFunc;
|
||||||
|
public IntPtr pFrom;
|
||||||
|
public IntPtr pTo;
|
||||||
|
public ushort fFlags;
|
||||||
|
public int fAnyOperationsAborted;
|
||||||
|
public IntPtr hNameMappings;
|
||||||
|
public IntPtr lpszProgressTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用传统 DllImport:结构体全是 blittable 字段,不需要 LibraryImport 的 unsafe 代码生成,
|
||||||
|
// 这样本层不引入 AllowUnsafeBlocks 依赖,任何项目链接这些源码都能直接编译。
|
||||||
|
[DllImport("shell32.dll", EntryPoint = "SHFileOperationW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
|
||||||
|
private static extern int SHFileOperation(ref SHFILEOPSTRUCTW lpFileOp);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 删除到回收站
|
||||||
|
|
||||||
|
/// <summary>把一批路径送进回收站。必须一次调用完成一批(Shell 语义)。</summary>
|
||||||
|
internal static (bool Success, bool Aborted, int Code) DeleteToRecycleBin(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
if (paths.Count == 0) return (true, false, 0);
|
||||||
|
|
||||||
|
// 注意:Shell API 只接受普通路径,绝不能带 \\?\ 前缀。
|
||||||
|
var from = PathHelper.AllocDoubleNullList(paths.Select(PathHelper.ToShellPath));
|
||||||
|
var op = new SHFILEOPSTRUCTW
|
||||||
|
{
|
||||||
|
hwnd = IntPtr.Zero,
|
||||||
|
wFunc = FO_DELETE,
|
||||||
|
pFrom = from,
|
||||||
|
pTo = IntPtr.Zero,
|
||||||
|
fFlags = DeleteFlags,
|
||||||
|
fAnyOperationsAborted = 0,
|
||||||
|
hNameMappings = IntPtr.Zero,
|
||||||
|
lpszProgressTitle = IntPtr.Zero
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var code = SHFileOperation(ref op);
|
||||||
|
var aborted = op.fAnyOperationsAborted != 0;
|
||||||
|
return (code == 0 && !aborted, aborted, code);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在专用 STA 线程上执行 SHFileOperationW。
|
||||||
|
/// Shell 函数在内部会做 COM/OLE 相关工作,用 STA 线程调用最稳妥;
|
||||||
|
/// 该线程是后台线程,不会阻塞 UI,也不会阻止进程退出。
|
||||||
|
/// </summary>
|
||||||
|
internal static Task<(bool Success, bool Aborted, int Code)> DeleteToRecycleBinAsync(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<(bool Success, bool Aborted, int Code)>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(DeleteToRecycleBin(paths));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "FluidExplorer-RecycleBin"
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
// 非 Windows 平台(本引擎实际只跑 Windows):直接以 MTA 启动。
|
||||||
|
}
|
||||||
|
|
||||||
|
thread.Start();
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string DescribeResult(int code) => code switch
|
||||||
|
{
|
||||||
|
0 => "成功",
|
||||||
|
2 => "找不到指定的文件。",
|
||||||
|
3 => "找不到指定的路径。",
|
||||||
|
5 => "拒绝访问。",
|
||||||
|
0x20 => "共享冲突(文件正被其它进程使用)。",
|
||||||
|
0x71 => "源与目标是同一个文件。",
|
||||||
|
0x72 => "多个源文件对应单个目标(目录)。",
|
||||||
|
0x73 => "源与目标处于不同目录。",
|
||||||
|
0x74 => "不能对根目录执行该操作。",
|
||||||
|
0x75 => "操作已被取消。",
|
||||||
|
0x76 => "目标位于源的子树中。",
|
||||||
|
0x78 => "访问源文件被拒绝。",
|
||||||
|
0x79 => "路径层级过深。",
|
||||||
|
0x7A => "目标过多。",
|
||||||
|
0x7C => "存在无效文件名。",
|
||||||
|
0x7D => "目标与源在同一目录树内。",
|
||||||
|
0x7E => "目标为文件,但源为文件夹。",
|
||||||
|
0x80 => "目标为文件夹,但源为文件。",
|
||||||
|
0x81 => "文件名过长。",
|
||||||
|
0x82 => "目标磁盘为 CD-ROM。",
|
||||||
|
0x83 => "目标磁盘为 DVD。",
|
||||||
|
0x84 => "目标磁盘为可刻录光盘。",
|
||||||
|
0x85 => "文件过大。",
|
||||||
|
0x86 => "源磁盘为 CD-ROM。",
|
||||||
|
0x87 => "源磁盘为 DVD。",
|
||||||
|
0x88 => "源磁盘为可刻录光盘。",
|
||||||
|
0xB7 => "超过文件名/路径长度上限。",
|
||||||
|
0x10000 => "目标上发生未指明的错误。",
|
||||||
|
_ => $"SHFileOperation 返回错误码 0x{code:X}。"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ $I / $R 定位
|
||||||
|
|
||||||
|
/// <summary>取当前进程用户的 SID 字符串(回收站目录名)。</summary>
|
||||||
|
internal static string? TryGetCurrentUserSid()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
return identity.User?.Value;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>某个卷的回收站目录:<卷>:\$Recycle.Bin\<SID>\(不存在返回 null)。</summary>
|
||||||
|
internal static string? GetRecycleBinDirectory(string volumeRoot)
|
||||||
|
{
|
||||||
|
var sid = TryGetCurrentUserSid();
|
||||||
|
if (string.IsNullOrEmpty(sid)) return null;
|
||||||
|
var dir = PathHelper.Combine(PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin"), sid);
|
||||||
|
return PathHelper.DirectoryExists(dir) ? dir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 快照:卷 → 该卷回收站里所有 $I 文件的完整路径集合。
|
||||||
|
/// 主体扫描 <卷>:\$Recycle.Bin\<当前用户 SID>\,同时兜底扫描其它 SID 目录
|
||||||
|
/// (进程可能以别的账户删除过文件)。
|
||||||
|
/// </summary>
|
||||||
|
internal static Dictionary<string, HashSet<string>> CaptureState(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
var volumes = new List<string>();
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
var root = PathHelper.GetVolumeRoot(path);
|
||||||
|
if (root.Length < 2) continue;
|
||||||
|
if (!volumes.Contains(root, StringComparer.OrdinalIgnoreCase)) volumes.Add(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var volume in volumes) result[volume] = EnumerateIndexFiles(volume);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> EnumerateIndexFiles(string volumeRoot)
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var binRoot = PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin");
|
||||||
|
if (!PathHelper.DirectoryExists(binRoot)) return set;
|
||||||
|
|
||||||
|
var directories = new List<string>();
|
||||||
|
|
||||||
|
var ownSidDirectory = GetRecycleBinDirectory(volumeRoot);
|
||||||
|
if (ownSidDirectory is not null) directories.Add(ownSidDirectory);
|
||||||
|
|
||||||
|
foreach (var sub in PathHelper.EnumerateChildrenSafe(binRoot))
|
||||||
|
{
|
||||||
|
if (!PathHelper.DirectoryExists(sub)) continue;
|
||||||
|
if (!directories.Contains(sub, StringComparer.OrdinalIgnoreCase)) directories.Add(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var directory in directories)
|
||||||
|
{
|
||||||
|
foreach (var file in PathHelper.EnumerateChildrenSafe(directory))
|
||||||
|
{
|
||||||
|
var name = PathHelper.GetFileName(file);
|
||||||
|
if (name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) set.Add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用"操作前后目录快照差集"找出本次新增的回收站条目,并用 $I 里的原始路径做二次确认,
|
||||||
|
/// 产出 (回收站内 $R 数据路径, 原始路径) 列表,可直接填入 <see cref="UndoEntry.Deleted"/>。
|
||||||
|
/// 定位失败的项也会产出记录(回收站路径为空串),撤销时按"无法还原"计入失败,不会崩溃。
|
||||||
|
/// </summary>
|
||||||
|
internal static (List<(string RecyclePath, string OriginalPath)> Items, List<string> Diagnostics) ResolveDeletedItems(
|
||||||
|
IReadOnlyList<string> deletedPaths,
|
||||||
|
Dictionary<string, HashSet<string>> before)
|
||||||
|
{
|
||||||
|
var items = new List<(string RecyclePath, string OriginalPath)>();
|
||||||
|
var diagnostics = new List<string>();
|
||||||
|
var remaining = new List<string>(deletedPaths);
|
||||||
|
|
||||||
|
foreach (var (volume, previous) in before)
|
||||||
|
{
|
||||||
|
var current = EnumerateIndexFiles(volume);
|
||||||
|
var added = current.Where(f => !previous.Contains(f)).OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
if (added.Count == 0) continue;
|
||||||
|
|
||||||
|
foreach (var indexFile in added)
|
||||||
|
{
|
||||||
|
var parsed = TryParseIndexFile(indexFile);
|
||||||
|
if (parsed is null)
|
||||||
|
{
|
||||||
|
diagnostics.Add($"$I 解析失败(长度/格式异常):{indexFile}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配策略:先按原始完整路径精确匹配,再退化为"同名文件"匹配
|
||||||
|
// (一次 SHFileOperation 调用内完成的条目,时间窗天然一致)。
|
||||||
|
var match = remaining.FirstOrDefault(p => SamePath(p, parsed.OriginalPath))
|
||||||
|
?? remaining.FirstOrDefault(p => string.Equals(
|
||||||
|
PathHelper.GetFileName(p), PathHelper.GetFileName(parsed.OriginalPath), StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (match is null)
|
||||||
|
{
|
||||||
|
diagnostics.Add($"回收站新增条目未能匹配本次删除路径:{indexFile}(原始路径 {parsed.OriginalPath})");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PathHelper.Exists(parsed.DataPath))
|
||||||
|
diagnostics.Add($"找到 $I 记录但缺少对应的 $R 数据文件:{parsed.DataPath}");
|
||||||
|
|
||||||
|
items.Add((parsed.DataPath, match));
|
||||||
|
remaining.Remove(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in remaining)
|
||||||
|
{
|
||||||
|
diagnostics.Add($"未能在回收站定位到“{path}”的 $I/$R 记录(可能被永久删除或回收站不可用),撤销时该项将按“无法还原”处理。");
|
||||||
|
items.Add((string.Empty, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (items, diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解析单个 $I 索引文件。</summary>
|
||||||
|
internal static RecycleBinItem? TryParseIndexFile(string indexFilePath)
|
||||||
|
{
|
||||||
|
byte[] bytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = File.ReadAllBytes(PathHelper.ToExtended(indexFilePath));
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.Length < 28) return null;
|
||||||
|
|
||||||
|
var version = BitConverter.ToInt64(bytes, 0);
|
||||||
|
var size = BitConverter.ToInt64(bytes, 8);
|
||||||
|
var fileTime = BitConverter.ToInt64(bytes, 16);
|
||||||
|
|
||||||
|
DateTime deletedUtc;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
deletedUtc = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue;
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
deletedUtc = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本 1(Vista/7)无文件名长度字段;版本 2(Win10+)多 4 字节。
|
||||||
|
var pathOffset = version >= 2 ? 28 : 24;
|
||||||
|
if (bytes.Length <= pathOffset) return null;
|
||||||
|
|
||||||
|
var payload = bytes.AsSpan(pathOffset);
|
||||||
|
if ((payload.Length & 1) == 1) payload = payload[..^1]; // UTF-16 按 2 字节对齐
|
||||||
|
|
||||||
|
var chars = MemoryMarshal.Cast<byte, char>(payload);
|
||||||
|
var terminator = chars.IndexOf('\0');
|
||||||
|
if (terminator >= 0) chars = chars[..terminator];
|
||||||
|
if (chars.Length == 0) return null;
|
||||||
|
|
||||||
|
var originalPath = new string(chars);
|
||||||
|
var name = PathHelper.GetFileName(indexFilePath);
|
||||||
|
if (name.Length < 3 || !name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
|
||||||
|
// $R 对应文件:把 $I 换成 $R 前缀即为回收站内的实际数据路径(目录同样适用)。
|
||||||
|
var dataPath = PathHelper.Combine(PathHelper.GetDirectoryName(indexFilePath), "$R" + name[2..]);
|
||||||
|
|
||||||
|
return new RecycleBinItem(indexFilePath, dataPath, originalPath, size, deletedUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>由 $R 数据路径反推对应的 $I 索引路径。</summary>
|
||||||
|
internal static string? GetIndexPathFromDataPath(string recycleDataPath)
|
||||||
|
{
|
||||||
|
var name = PathHelper.GetFileName(recycleDataPath);
|
||||||
|
if (name.Length < 3 || !name.StartsWith("$R", StringComparison.OrdinalIgnoreCase)) return null;
|
||||||
|
return PathHelper.Combine(PathHelper.GetDirectoryName(recycleDataPath), "$I" + name[2..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SamePath(string a, string b)
|
||||||
|
{
|
||||||
|
var na = (PathHelper.TryGetFullPath(a) ?? a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var nb = (PathHelper.TryGetFullPath(b) ?? b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using FluidExplorer.Services.Icons;
|
||||||
|
using FluidExplorer.Services.Operations;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 降级占位实现:当真实现(外壳图标服务 / 文件操作引擎)在构造阶段抛异常时兜底,
|
||||||
|
/// 保证主界面还能打开浏览(图标为空、操作给出明确失败提示),而不是整个程序起不来。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PlaceholderIconService : IIconService
|
||||||
|
{
|
||||||
|
public Task<ImageSource?> GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<ImageSource?>(null);
|
||||||
|
|
||||||
|
public Task<ImageSource?> GetThumbnailAsync(string path, int size, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<ImageSource?>(null);
|
||||||
|
|
||||||
|
public Task<ImageSource?> GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<ImageSource?>(null);
|
||||||
|
|
||||||
|
public ImageSource? GetSpecialFolderIcon(string parsingName, int size) => null;
|
||||||
|
|
||||||
|
public void ClearCache() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PlaceholderOperationService : IFileOperationService
|
||||||
|
{
|
||||||
|
private readonly List<FileOperationJob> _jobs = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<FileOperationJob> Jobs => _jobs;
|
||||||
|
public event EventHandler? JobsChanged;
|
||||||
|
|
||||||
|
// 占位实现永远不会产生撤销记录,事件显式忽略订阅以避免空事件告警
|
||||||
|
public event EventHandler? UndoStackChanged
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Func<ConflictInfo, Task<ConflictResolution>>? ConflictResolver { get; set; }
|
||||||
|
public bool CanUndo => false;
|
||||||
|
public string? UndoDescription => null;
|
||||||
|
|
||||||
|
private FileOperationJob Fail(FileOperationKind kind, IReadOnlyList<string> sources, string destination)
|
||||||
|
{
|
||||||
|
var job = new FileOperationJob
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
Title = "文件操作服务不可用",
|
||||||
|
Sources = sources,
|
||||||
|
Destination = destination,
|
||||||
|
State = JobState.Failed,
|
||||||
|
Error = "文件操作引擎尚未接入。"
|
||||||
|
};
|
||||||
|
_jobs.Add(job);
|
||||||
|
JobsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueCopy(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
|
||||||
|
=> Fail(FileOperationKind.Copy, sources, destination);
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueMove(IReadOnlyList<string> sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
|
||||||
|
=> Fail(FileOperationKind.Move, sources, destination);
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueDelete(IReadOnlyList<string> paths, bool permanent = false)
|
||||||
|
=> Fail(FileOperationKind.Delete, paths, string.Empty);
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueRename(string path, string newName) => Fail(FileOperationKind.Rename, [path], newName);
|
||||||
|
|
||||||
|
public FileOperationJob EnqueueNewFolder(string parentDirectory, string name) => Fail(FileOperationKind.NewFolder, [parentDirectory], name);
|
||||||
|
|
||||||
|
public void Pause(Guid jobId) { }
|
||||||
|
public void Resume(Guid jobId) { }
|
||||||
|
public void Cancel(Guid jobId) { }
|
||||||
|
public void ClearFinished() { }
|
||||||
|
public Task<OperationResult> UndoAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new OperationResult(false, 0, 0, 0, "没有可撤销的操作。"));
|
||||||
|
|
||||||
|
public Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult((0L, 0));
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace FluidExplorer.Services.Search;
|
||||||
|
|
||||||
|
public enum IndexState
|
||||||
|
{
|
||||||
|
NotStarted,
|
||||||
|
RequiresElevation,
|
||||||
|
Building,
|
||||||
|
Ready,
|
||||||
|
Watching,
|
||||||
|
Failed,
|
||||||
|
Stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>索引里的一条记录(NTFS USN 数据的最小集合)。</summary>
|
||||||
|
public readonly record struct IndexedEntry(
|
||||||
|
ulong Frn,
|
||||||
|
ulong ParentFrn,
|
||||||
|
string Name,
|
||||||
|
bool IsDirectory,
|
||||||
|
long Size,
|
||||||
|
DateTime ModifiedUtc,
|
||||||
|
uint Attributes)
|
||||||
|
{
|
||||||
|
/// <summary>非 NTFS / 非索引来源(例如回退扫描器)可直接携带完整路径。</summary>
|
||||||
|
public string? FullPath { get; init; }
|
||||||
|
|
||||||
|
public string Extension
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (IsDirectory) return string.Empty;
|
||||||
|
var i = Name.LastIndexOf('.');
|
||||||
|
return i > 0 && i < Name.Length - 1 ? Name[(i + 1)..] : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IndexStateChangedEventArgs(IndexState state, string? message = null) : EventArgs
|
||||||
|
{
|
||||||
|
public IndexState State { get; } = state;
|
||||||
|
public string? Message { get; } = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单卷文件索引:Everything 式体验的核心。
|
||||||
|
/// 实现必须做到:构建阶段不阻塞调用线程(内部自行使用线程池)、
|
||||||
|
/// 支持增量更新(USN 日志)、查询使用并行扫描并在毫秒级返回。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFileIndex
|
||||||
|
{
|
||||||
|
/// <summary>卷根,例如 "C:\"。</summary>
|
||||||
|
string VolumeRoot { get; }
|
||||||
|
|
||||||
|
IndexState State { get; }
|
||||||
|
long EntryCount { get; }
|
||||||
|
|
||||||
|
event EventHandler<IndexStateChangedEventArgs>? StateChanged;
|
||||||
|
|
||||||
|
/// <summary>建立初始索引(枚举 MFT / 扫描)。可重复调用,完成后自动转入 Watching。</summary>
|
||||||
|
Task BuildAsync(IProgress<double>? progress, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>启动增量监听(USN journal),保持索引实时。</summary>
|
||||||
|
void StartWatching();
|
||||||
|
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询。实现约定:
|
||||||
|
/// 1) 先按 IncludeTerms/Extensions/大小/时间/属性过滤(纯内存操作,必须并行化);
|
||||||
|
/// 2) 仅当 <see cref="SearchQuery.PathFilter"/> 不为空时,才对已命中的候选调用路径解析;
|
||||||
|
/// 3) 命中数达到 maxResults 即可提前返回,但不要漏掉更"好"的匹配(短名优先)。
|
||||||
|
/// </summary>
|
||||||
|
IEnumerable<IndexedEntry> Query(SearchQuery query, int maxResults, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>把 FRN 解析为完整路径(内部要缓存父链解析结果)。失败返回 false。</summary>
|
||||||
|
bool TryResolvePath(ulong frn, out string fullPath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Search;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything 风格的查询:空格 = AND,| = OR,"引号" = 精确短语,!term = 排除,
|
||||||
|
/// 支持 ext: size: dm: dc: folder: file: path: 以及 * ? 通配符。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchQuery
|
||||||
|
{
|
||||||
|
public string RawText { get; init; } = string.Empty;
|
||||||
|
public List<string> IncludeTerms { get; } = [];
|
||||||
|
public List<string> ExcludeTerms { get; } = [];
|
||||||
|
public List<string> IncludeRegexLike { get; } = []; // 含通配符的项
|
||||||
|
public List<string> Extensions { get; } = [];
|
||||||
|
public List<string> ExcludeExtensions { get; } = [];
|
||||||
|
public bool DirectoriesOnly { get; set; }
|
||||||
|
public bool FilesOnly { get; set; }
|
||||||
|
public long? MinSize { get; set; }
|
||||||
|
public long? MaxSize { get; set; }
|
||||||
|
public DateTime? ModifiedAfter { get; set; }
|
||||||
|
public DateTime? ModifiedBefore { get; set; }
|
||||||
|
public DateTime? CreatedAfter { get; set; }
|
||||||
|
public string? PathFilter { get; set; }
|
||||||
|
public bool MatchWholePath { get; set; }
|
||||||
|
public bool IsEmpty => IncludeTerms.Count == 0 && IncludeRegexLike.Count == 0 && Extensions.Count == 0
|
||||||
|
&& !DirectoriesOnly && !FilesOnly && MinSize is null && MaxSize is null
|
||||||
|
&& ModifiedAfter is null && ModifiedBefore is null && CreatedAfter is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SearchQueryParser
|
||||||
|
{
|
||||||
|
public static SearchQuery Parse(string? text)
|
||||||
|
{
|
||||||
|
var q = new SearchQuery { RawText = text ?? string.Empty };
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return q;
|
||||||
|
if (text.StartsWith('*') && text.EndsWith('*') && text.Length > 2)
|
||||||
|
{
|
||||||
|
q.IncludeTerms.Add(text.Trim('*'));
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var token in Tokenize(text))
|
||||||
|
{
|
||||||
|
if (token.Length == 0) continue;
|
||||||
|
var span = token.AsSpan();
|
||||||
|
if (span[0] == '!')
|
||||||
|
{
|
||||||
|
var t = token[1..].Trim();
|
||||||
|
if (t.Length == 0) continue;
|
||||||
|
if (TryExt(t, out var ex)) q.ExcludeExtensions.Add(ex);
|
||||||
|
else q.ExcludeTerms.Add(t.Trim('"'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryPrefix(span, "ext:", out var extValue))
|
||||||
|
{
|
||||||
|
foreach (var e in extValue.Split([';', ','], StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
q.Extensions.Add(e.Trim().TrimStart('.').ToLowerInvariant());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TryPrefix(span, "size:", out var sizeValue))
|
||||||
|
{
|
||||||
|
ParseSize(sizeValue, q);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TryPrefix(span, "dm:", out var dm))
|
||||||
|
{
|
||||||
|
if (TryParseDate(dm, out var d)) q.ModifiedAfter = d;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TryPrefix(span, "dc:", out var dc))
|
||||||
|
{
|
||||||
|
if (TryParseDate(dc, out var d)) q.CreatedAfter = d;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TryPrefix(span, "folder:", out _) || token.Equals("folder:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
q.DirectoriesOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.Equals("file:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
q.FilesOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TryPrefix(span, "path:", out var pathValue))
|
||||||
|
{
|
||||||
|
q.PathFilter = pathValue.Trim('"');
|
||||||
|
q.MatchWholePath = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.Equals("dm:today", StringComparison.OrdinalIgnoreCase)) { q.ModifiedAfter = DateTime.Today; continue; }
|
||||||
|
|
||||||
|
var cleaned = token.Trim('"');
|
||||||
|
if (cleaned.Length == 0) continue;
|
||||||
|
if (cleaned.IndexOfAny(['*', '?']) >= 0) q.IncludeRegexLike.Add(cleaned);
|
||||||
|
else q.IncludeTerms.Add(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryExt(string token, out string ext)
|
||||||
|
{
|
||||||
|
ext = string.Empty;
|
||||||
|
if (!TryPrefix(token.AsSpan(), "ext:", out var v)) return false;
|
||||||
|
ext = v.Trim().TrimStart('.').ToLowerInvariant();
|
||||||
|
return ext.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryPrefix(ReadOnlySpan<char> token, string prefix, out string value)
|
||||||
|
{
|
||||||
|
value = string.Empty;
|
||||||
|
if (token.Length <= prefix.Length) return false;
|
||||||
|
if (!token.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
value = token[prefix.Length..].ToString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseSize(string value, SearchQuery q)
|
||||||
|
{
|
||||||
|
value = value.Trim();
|
||||||
|
if (value.Length == 0) return;
|
||||||
|
var op = '=';
|
||||||
|
if (value[0] is '>' or '<' or '=') { op = value[0]; value = value[1..]; }
|
||||||
|
else if (value.StartsWith(">=", StringComparison.Ordinal)) { op = '>'; value = value[2..]; }
|
||||||
|
else if (value.StartsWith("<=", StringComparison.Ordinal)) { op = '<'; value = value[2..]; }
|
||||||
|
|
||||||
|
double mul = 1;
|
||||||
|
var lower = value.ToLowerInvariant();
|
||||||
|
foreach (var (suffix, factor) in SizeSuffixes)
|
||||||
|
{
|
||||||
|
if (lower.EndsWith(suffix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
mul = factor;
|
||||||
|
value = value[..^suffix.Length];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!double.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var num)) return;
|
||||||
|
var bytes = (long)(num * mul);
|
||||||
|
switch (op)
|
||||||
|
{
|
||||||
|
case '>': q.MinSize = bytes; break;
|
||||||
|
case '<': q.MaxSize = bytes; break;
|
||||||
|
default: q.MinSize = bytes; q.MaxSize = bytes; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly (string, double)[] SizeSuffixes =
|
||||||
|
[
|
||||||
|
("kb", 1024d), ("mb", 1024d * 1024), ("gb", 1024d * 1024 * 1024), ("tb", 1024d * 1024 * 1024 * 1024),
|
||||||
|
("k", 1024d), ("m", 1024d * 1024), ("g", 1024d * 1024 * 1024), ("b", 1d)
|
||||||
|
];
|
||||||
|
|
||||||
|
private static bool TryParseDate(string value, out DateTime date)
|
||||||
|
{
|
||||||
|
date = default;
|
||||||
|
var v = value.Trim().ToLowerInvariant();
|
||||||
|
var now = DateTime.Now;
|
||||||
|
switch (v)
|
||||||
|
{
|
||||||
|
case "today": date = now.Date; return true;
|
||||||
|
case "yesterday": date = now.Date.AddDays(-1); return true;
|
||||||
|
case "thisweek": date = now.Date.AddDays(-(int)now.DayOfWeek); return true;
|
||||||
|
case "thismonth": date = new DateTime(now.Year, now.Month, 1); return true;
|
||||||
|
case "thisyear": date = new DateTime(now.Year, 1, 1); return true;
|
||||||
|
}
|
||||||
|
if (v.EndsWith('d') && int.TryParse(v[..^1], out var days)) { date = now.AddDays(-days); return true; }
|
||||||
|
if (v.EndsWith('h') && int.TryParse(v[..^1], out var hours)) { date = now.AddHours(-hours); return true; }
|
||||||
|
if (v.EndsWith('w') && int.TryParse(v[..^1], out var weeks)) { date = now.AddDays(-7 * weeks); return true; }
|
||||||
|
return DateTime.TryParse(v, CultureInfo.CurrentCulture, DateTimeStyles.None, out date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按空格切词,但保留引号内的空格。</summary>
|
||||||
|
private static IEnumerable<string> Tokenize(string text)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
bool inQuotes = false;
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
if (ch == '"')
|
||||||
|
{
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
sb.Append(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inQuotes && char.IsWhiteSpace(ch))
|
||||||
|
{
|
||||||
|
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.Append(ch);
|
||||||
|
}
|
||||||
|
if (sb.Length > 0) yield return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using FluidExplorer.Services.Shell;
|
||||||
|
using FluidExplorer.Services.Search;
|
||||||
|
using FluidExplorer.Services.FileSystem;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Search;
|
||||||
|
|
||||||
|
/// <summary>一条搜索结果(对外给 UI 用的扁平结构)。</summary>
|
||||||
|
public sealed record SearchHit(
|
||||||
|
string Path,
|
||||||
|
string Name,
|
||||||
|
string Directory,
|
||||||
|
bool IsDirectory,
|
||||||
|
long Size,
|
||||||
|
DateTime ModifiedUtc)
|
||||||
|
{
|
||||||
|
public string Extension
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (IsDirectory) return string.Empty;
|
||||||
|
var i = Name.LastIndexOf('.');
|
||||||
|
return i > 0 && i < Name.Length - 1 ? Name[(i + 1)..].ToLowerInvariant() : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SearchOutcome(
|
||||||
|
IReadOnlyList<SearchHit> Hits,
|
||||||
|
bool Truncated,
|
||||||
|
bool UsedIndex,
|
||||||
|
int IndexedVolumes,
|
||||||
|
TimeSpan Elapsed,
|
||||||
|
string? Note = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索门面:优先使用 NTFS 索引(毫秒级、全盘),
|
||||||
|
/// 没有索引时回退为带时间预算的实时枚举(并明确告诉用户"未使用索引")。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SearchService(IFileSystemService fileSystem)
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, IFileIndex> _indexes = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly IFileSystemService _fileSystem = fileSystem;
|
||||||
|
|
||||||
|
/// <summary>由 AppServices 注入的具体索引实现工厂(这样本层不依赖具体互操作实现)。</summary>
|
||||||
|
public Func<string, IFileIndex?>? IndexFactory { get; set; }
|
||||||
|
|
||||||
|
public event EventHandler? IndexStateChanged;
|
||||||
|
|
||||||
|
public bool HasAnyIndex => _indexes.Count > 0;
|
||||||
|
public int IndexedVolumeCount => _indexes.Count;
|
||||||
|
public long TotalIndexedEntries => _indexes.Values.Sum(i => i.EntryCount);
|
||||||
|
|
||||||
|
public IndexState AggregateState
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_indexes.Count == 0) return IndexState.NotStarted;
|
||||||
|
if (_indexes.Values.Any(i => i.State == IndexState.RequiresElevation)) return IndexState.RequiresElevation;
|
||||||
|
if (_indexes.Values.Any(i => i.State == IndexState.Building)) return IndexState.Building;
|
||||||
|
if (_indexes.Values.Any(i => i.State == IndexState.Failed)) return IndexState.Failed;
|
||||||
|
if (_indexes.Values.All(i => i.State is IndexState.Ready or IndexState.Watching)) return IndexState.Ready;
|
||||||
|
return IndexState.NotStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>启动指定卷的索引(后台构建,不阻塞 UI)。</summary>
|
||||||
|
public IFileIndex? EnsureIndex(string volumeRoot)
|
||||||
|
{
|
||||||
|
if (_indexes.TryGetValue(volumeRoot, out var existing)) return existing;
|
||||||
|
var created = IndexFactory?.Invoke(volumeRoot);
|
||||||
|
if (created is null) return null;
|
||||||
|
created.StateChanged += (_, _) => IndexStateChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
_indexes[volumeRoot] = created;
|
||||||
|
IndexStateChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IFileIndex? GetIndex(string volumeRoot)
|
||||||
|
=> _indexes.TryGetValue(volumeRoot, out var index) ? index : null;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<IFileIndex> AllIndexes => _indexes.Values;
|
||||||
|
|
||||||
|
/// <summary>为当前所有固定卷建立索引(默认行为:只索引本地固定磁盘,避免扫网络盘)。</summary>
|
||||||
|
public async Task BuildIndexesAsync(IEnumerable<string> volumeRoots, IProgress<(string Volume, double Progress)>? progress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (var root in volumeRoots)
|
||||||
|
{
|
||||||
|
var index = EnsureIndex(root);
|
||||||
|
if (index is null) continue;
|
||||||
|
var capturedRoot = root;
|
||||||
|
var sub = progress is null ? null : new Progress<double>(p => progress.Report((capturedRoot, p)));
|
||||||
|
await index.BuildAsync(sub, ct).ConfigureAwait(false);
|
||||||
|
if (index.State is IndexState.Ready or IndexState.Watching) index.StartWatching();
|
||||||
|
IndexStateChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>执行搜索。Global 走索引;索引不可用时退回实时扫描(并如实告知用户)。</summary>
|
||||||
|
public async Task<SearchOutcome> SearchAsync(SearchQuery query, SearchScope scope, string? basePath, int maxResults, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (query.IsEmpty) return new SearchOutcome([], false, false, 0, TimeSpan.Zero, "请输入搜索内容");
|
||||||
|
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var hits = new List<SearchHit>(maxResults > 0 ? Math.Min(maxResults, 4096) : 1024);
|
||||||
|
var truncated = false;
|
||||||
|
var indexedVolumeCount = 0;
|
||||||
|
|
||||||
|
if (scope == SearchScope.Global && _indexes.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var (root, index) in _indexes)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (index.State is not (IndexState.Ready or IndexState.Watching)) continue;
|
||||||
|
indexedVolumeCount++;
|
||||||
|
var remaining = maxResults - hits.Count;
|
||||||
|
if (remaining <= 0) { truncated = true; break; }
|
||||||
|
foreach (var entry in index.Query(query, remaining, ct))
|
||||||
|
{
|
||||||
|
var path = entry.FullPath;
|
||||||
|
if (path is null && !index.TryResolvePath(entry.Frn, out path)) continue;
|
||||||
|
if (!MatchesExtendedFilters(entry, query, path!)) continue;
|
||||||
|
hits.Add(new SearchHit(
|
||||||
|
path!,
|
||||||
|
entry.Name,
|
||||||
|
PathHelper.GetParent(path!),
|
||||||
|
entry.IsDirectory,
|
||||||
|
entry.Size,
|
||||||
|
entry.ModifiedUtc == default ? DateTime.MinValue : DateTime.SpecifyKind(entry.ModifiedUtc, DateTimeKind.Utc)));
|
||||||
|
if (hits.Count >= maxResults) { truncated = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indexedVolumeCount > 0)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
return new SearchOutcome(hits, truncated, true, indexedVolumeCount, sw.Elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一个可用索引都没有(未建、无管理员权限、非 NTFS):不要返回空结果,
|
||||||
|
// 而是退回实时扫描,并明确告诉用户为什么慢、怎么才能变快。
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:实时枚举(限定范围 + 时间预算 + 结果上限,绝不卡住界面)
|
||||||
|
var scanRoot = !string.IsNullOrEmpty(basePath) && Directory.Exists(basePath)
|
||||||
|
? basePath
|
||||||
|
: SafeSystemDriveRoot();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(scanRoot))
|
||||||
|
{
|
||||||
|
var options = new EnumerationOptions
|
||||||
|
{
|
||||||
|
RecurseSubdirectories = true,
|
||||||
|
IgnoreInaccessible = true,
|
||||||
|
AttributesToSkip = 0,
|
||||||
|
MaxRecursionDepth = 32
|
||||||
|
};
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var budget = TimeSpan.FromSeconds(20);
|
||||||
|
foreach (var path in Directory.EnumerateFileSystemEntries(scanRoot, "*", options))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (sw.Elapsed > budget) { truncated = true; break; }
|
||||||
|
string name = PathHelper.GetName(path);
|
||||||
|
if (!MatchesNameOnly(name, query)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var isDir = Directory.Exists(path);
|
||||||
|
var info = isDir ? null : new FileInfo(path);
|
||||||
|
hits.Add(new SearchHit(path, name, PathHelper.GetParent(path), isDir,
|
||||||
|
info?.Length ?? 0, info?.LastWriteTimeUtc ?? DateTime.MinValue));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
hits.Add(new SearchHit(path, name, PathHelper.GetParent(path), false, 0, DateTime.MinValue));
|
||||||
|
}
|
||||||
|
if (hits.Count >= maxResults) { truncated = true; break; }
|
||||||
|
}
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
var note = _indexes.Count == 0
|
||||||
|
? "未启用 NTFS 索引,本次为实时扫描(可在设置中开启索引以获得毫秒级全盘搜索)"
|
||||||
|
: $"索引尚未就绪,已在「{scanRoot}」实时扫描。以管理员身份重启可建立 NTFS 索引,全盘搜索将提升到毫秒级";
|
||||||
|
return new SearchOutcome(hits, truncated, false, 0, sw.Elapsed, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? SafeSystemDriveRoot()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = Path.GetPathRoot(Environment.SystemDirectory);
|
||||||
|
return string.IsNullOrEmpty(root) ? null : root;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesExtendedFilters(IndexedEntry entry, SearchQuery query, string path)
|
||||||
|
{
|
||||||
|
if (query.MinSize is { } min && entry.Size >= 0 && entry.Size < min) return false;
|
||||||
|
if (query.MaxSize is { } max && entry.Size >= 0 && entry.Size > max) return false;
|
||||||
|
if (!string.IsNullOrEmpty(query.PathFilter))
|
||||||
|
{
|
||||||
|
if (!path.Contains(query.PathFilter, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
}
|
||||||
|
if (query.MatchWholePath && query.IncludeTerms.Count > 0)
|
||||||
|
{
|
||||||
|
// 名字里已经命中就不必再看路径;名字没命中的,允许整条路径命中
|
||||||
|
foreach (var term in query.IncludeTerms)
|
||||||
|
{
|
||||||
|
if (entry.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
if (path.Contains(term, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesNameOnly(string name, SearchQuery query)
|
||||||
|
{
|
||||||
|
foreach (var term in query.IncludeTerms)
|
||||||
|
if (!name.Contains(term, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
foreach (var bad in query.ExcludeTerms)
|
||||||
|
if (name.Contains(bad, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
if (query.Extensions.Count > 0)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(name).TrimStart('.').ToLowerInvariant();
|
||||||
|
if (!query.Extensions.Contains(ext)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Search.Usn;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 增量监听的<b>回退通道</b>:ReadDirectoryChangesW。
|
||||||
|
///
|
||||||
|
/// 什么情况下用:USN 变更日志不可用(卷上没日志且没权限创建、被策略禁用、日志刚被删除等)。
|
||||||
|
/// 这条路径不需要任何特殊权限,普通用户也能跑,从而保证「索引实时」这个卖点不落空。
|
||||||
|
///
|
||||||
|
/// 与 USN 的差别与应对:
|
||||||
|
/// * RDCW 只给相对<b>路径</b>,不给 FRN。这里用 CreateFileW(FILE_READ_ATTRIBUTES) +
|
||||||
|
/// GetFileInformationByHandle 取句柄上的 FileIndex —— 它与 USN 的 FRN 完全同口径
|
||||||
|
/// (低 48 位记录号 + 高 16 位序列号),因此能直接命中同一个索引条目。
|
||||||
|
/// * 新增/改名/内容变化都伴随着文件仍然存在,可以 stat 出来 → 直接 upsert。
|
||||||
|
/// * 删除只剩一个路径(stat 必然失败),无法反查 FRN。应对:把受影响的<b>目录</b>记下来,
|
||||||
|
/// 批处理结束后对该目录做一次「磁盘现状 vs 索引」的对账(<see cref="UsnVolumeIndex.ResyncDirectoryFromDisk"/>),
|
||||||
|
/// 只把索引里存在、磁盘上已消失的孩子打墓碑。对账按目录去重、每轮有上限,代价可控。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DirectoryChangeWatcher
|
||||||
|
{
|
||||||
|
private const int ReadBufferSize = 64 * 1024;
|
||||||
|
|
||||||
|
/// <summary>每轮最多对账多少个目录,防止一次批量删除把 CPU 打满。</summary>
|
||||||
|
private const int MaxResyncDirectoriesPerPass = 256;
|
||||||
|
|
||||||
|
private static readonly uint NotifyFilter =
|
||||||
|
UsnNative.FILE_NOTIFY_CHANGE_FILE_NAME |
|
||||||
|
UsnNative.FILE_NOTIFY_CHANGE_DIR_NAME |
|
||||||
|
UsnNative.FILE_NOTIFY_CHANGE_SIZE |
|
||||||
|
UsnNative.FILE_NOTIFY_CHANGE_LAST_WRITE |
|
||||||
|
UsnNative.FILE_NOTIFY_CHANGE_ATTRIBUTES;
|
||||||
|
|
||||||
|
private readonly UsnVolumeIndex _index;
|
||||||
|
private readonly string _root;
|
||||||
|
private readonly ConcurrentDictionary<string, ulong> _directoryFrnCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _pendingResync = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly List<string> _resyncScratch = [];
|
||||||
|
|
||||||
|
private SafeFileHandle? _handle;
|
||||||
|
private volatile bool _stopRequested;
|
||||||
|
|
||||||
|
internal DirectoryChangeWatcher(UsnVolumeIndex index)
|
||||||
|
{
|
||||||
|
_index = index;
|
||||||
|
_root = index.VolumeRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>打断阻塞中的 ReadDirectoryChangesW:置停止标志 + 关目录句柄(双保险)。</summary>
|
||||||
|
internal void RequestStop()
|
||||||
|
{
|
||||||
|
_stopRequested = true;
|
||||||
|
Interlocked.Exchange(ref _handle, null)?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>打开卷根目录句柄。返回 false 时 <paramref name="error"/> 给出中文原因。</summary>
|
||||||
|
internal bool TryOpen(out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
var handle = UsnNative.CreateFileW(
|
||||||
|
_root,
|
||||||
|
UsnNative.FILE_LIST_DIRECTORY,
|
||||||
|
UsnNative.FILE_SHARE_READ | UsnNative.FILE_SHARE_WRITE | UsnNative.FILE_SHARE_DELETE,
|
||||||
|
IntPtr.Zero,
|
||||||
|
UsnNative.OPEN_EXISTING,
|
||||||
|
UsnNative.FILE_FLAG_BACKUP_SEMANTICS, // 打开目录必须带这个
|
||||||
|
IntPtr.Zero);
|
||||||
|
|
||||||
|
if (handle.IsInvalid)
|
||||||
|
{
|
||||||
|
int openError = Marshal.GetLastWin32Error();
|
||||||
|
handle.Dispose();
|
||||||
|
error = UsnNative.DescribeError(openError, $"无法以 FILE_LIST_DIRECTORY 打开 {_root}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Volatile.Write(ref _handle, handle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>阻塞式监听循环,直到 <see cref="RequestStop"/> 或句柄被关闭。</summary>
|
||||||
|
internal void Run()
|
||||||
|
{
|
||||||
|
var handle = Volatile.Read(ref _handle);
|
||||||
|
if (handle is null) return;
|
||||||
|
var buffer = new byte[ReadBufferSize];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!_stopRequested)
|
||||||
|
{
|
||||||
|
uint returned;
|
||||||
|
bool ok;
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* p = buffer)
|
||||||
|
{
|
||||||
|
ok = UsnNative.ReadDirectoryChangesW(
|
||||||
|
handle, p, (uint)buffer.Length, true, NotifyFilter, out returned, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
int readError = Marshal.GetLastWin32Error();
|
||||||
|
if (_stopRequested
|
||||||
|
|| readError is UsnNative.ERROR_OPERATION_ABORTED or UsnNative.ERROR_CANCELLED or UsnNative.ERROR_INVALID_HANDLE)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 单次失败不放弃:报给 UI 后继续(例如枚举期间目录被临时独占)
|
||||||
|
_index.ReportWatchIssue($"ReadDirectoryChangesW 出错:{UsnNative.DescribeError(readError)}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (returned == 0)
|
||||||
|
{
|
||||||
|
// 变更缓冲区溢出:期间的事件已经丢了,只能提示 UI 做一次重建
|
||||||
|
_index.ReportWatchIssue("变更缓冲区溢出,部分变更已丢失,建议重建索引以保持一致。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessEvents(buffer.AsSpan(0, (int)returned));
|
||||||
|
FlushPendingResync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _handle, null)?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessEvents(ReadOnlySpan<byte> buffer)
|
||||||
|
{
|
||||||
|
int offset = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (offset + UsnNative.FileNotifyInformation.HeaderSize > buffer.Length) break;
|
||||||
|
|
||||||
|
uint nextEntry = BinaryPrimitives.ReadUInt32LittleEndian(buffer[offset..]);
|
||||||
|
uint action = BinaryPrimitives.ReadUInt32LittleEndian(buffer[(offset + 4)..]);
|
||||||
|
int nameBytes = (int)BinaryPrimitives.ReadUInt32LittleEndian(buffer[(offset + 8)..]);
|
||||||
|
|
||||||
|
int nameOffset = offset + UsnNative.FileNotifyInformation.HeaderSize;
|
||||||
|
if (nameBytes <= 0 || nameOffset + nameBytes > buffer.Length) break;
|
||||||
|
|
||||||
|
// 文件名是相对被监听目录(这里是卷根)的路径,形如 "Users\Public\x.txt",不以 NUL 结尾
|
||||||
|
var relative = MemoryMarshal.Cast<byte, char>(buffer.Slice(nameOffset, nameBytes));
|
||||||
|
Handle(relative, action);
|
||||||
|
|
||||||
|
if (nextEntry == 0) break;
|
||||||
|
offset += (int)nextEntry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Handle(ReadOnlySpan<char> relative, uint action)
|
||||||
|
{
|
||||||
|
if (relative.Length == 0) return;
|
||||||
|
var fullPath = string.Concat(_root, relative);
|
||||||
|
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case UsnNative.FILE_ACTION_ADDED:
|
||||||
|
case UsnNative.FILE_ACTION_RENAMED_NEW_NAME:
|
||||||
|
case UsnNative.FILE_ACTION_MODIFIED:
|
||||||
|
UpsertFromDisk(fullPath);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UsnNative.FILE_ACTION_REMOVED:
|
||||||
|
case UsnNative.FILE_ACTION_RENAMED_OLD_NAME:
|
||||||
|
QueueDirectoryResync(fullPath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按路径 stat 出 FRN + 元数据,然后按 FRN 落入索引(存在则更新,不存在则新增)。</summary>
|
||||||
|
private void UpsertFromDisk(string fullPath)
|
||||||
|
{
|
||||||
|
if (!UsnNative.TryStatPath(fullPath, out var info)) return; // 文件已再次消失等,忽略
|
||||||
|
|
||||||
|
var name = Path.GetFileName(fullPath.AsSpan());
|
||||||
|
if (name.Length == 0) return;
|
||||||
|
|
||||||
|
ulong parentFrn = 0;
|
||||||
|
var parentPath = Path.GetDirectoryName(fullPath);
|
||||||
|
if (!string.IsNullOrEmpty(parentPath) && !parentPath.Equals(_root, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
parentFrn = GetDirectoryFrn(parentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isDirectory = (info.FileAttributes & UsnNative.FILE_ATTRIBUTE_DIRECTORY) != 0;
|
||||||
|
_index.UpsertFromFileSystem(
|
||||||
|
info.FileIndex,
|
||||||
|
parentFrn,
|
||||||
|
name,
|
||||||
|
isDirectory,
|
||||||
|
isDirectory ? 0 : info.FileSize,
|
||||||
|
UsnVolumeIndex.FileTimeToTicks(info.LastWriteTime.ToInt64()),
|
||||||
|
info.FileAttributes);
|
||||||
|
|
||||||
|
if (isDirectory) _directoryFrnCache[fullPath] = UsnNative.NormalizeFrn(info.FileIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ulong GetDirectoryFrn(string directoryPath)
|
||||||
|
{
|
||||||
|
if (_directoryFrnCache.TryGetValue(directoryPath, out var cached)) return cached;
|
||||||
|
if (!UsnNative.TryStatPath(directoryPath, out var info)) return 0;
|
||||||
|
var frn = UsnNative.NormalizeFrn(info.FileIndex);
|
||||||
|
_directoryFrnCache[directoryPath] = frn;
|
||||||
|
return frn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueDirectoryResync(string fullPath)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(fullPath);
|
||||||
|
if (!string.IsNullOrEmpty(directory)) _pendingResync.Add(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushPendingResync()
|
||||||
|
{
|
||||||
|
if (_pendingResync.Count == 0) return;
|
||||||
|
|
||||||
|
_resyncScratch.Clear();
|
||||||
|
foreach (var directory in _pendingResync)
|
||||||
|
{
|
||||||
|
_resyncScratch.Add(directory);
|
||||||
|
if (_resyncScratch.Count >= MaxResyncDirectoriesPerPass) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int removed = 0;
|
||||||
|
foreach (var directory in _resyncScratch)
|
||||||
|
{
|
||||||
|
_pendingResync.Remove(directory);
|
||||||
|
removed += _index.ResyncDirectoryFromDisk(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) _index.ReportWatchIssue(null, removed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Search.Usn;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 索引的紧凑存储:结构体数组(Struct-of-Arrays)而不是对象数组。
|
||||||
|
///
|
||||||
|
/// 每条记录只占 45 字节(8+8+8+8+8+4+1),100 万条约 45MB,
|
||||||
|
/// 再加上名字池(约 2 字节/字符)就构成整个索引;绝无 per-entry 的对象头与 string。
|
||||||
|
///
|
||||||
|
/// 并发模型(读多写极少):
|
||||||
|
/// * <see cref="Count"/> 用 volatile 发布:写入方先写满数据,最后自增 Count;读方只需读一次 Count 再顺序访问。
|
||||||
|
/// * 结构只增不减(删除只打墓碑标志位),因此已经发布的 [0, Count) 区间永远有效。
|
||||||
|
/// * 容量不足时 <see cref="Grow"/> 采用 copy-on-write,整体替换数组;老快照对正在查询的线程依然合法。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class IndexStore
|
||||||
|
{
|
||||||
|
internal const byte FlagDeleted = 0x01;
|
||||||
|
internal const byte FlagDirectory = 0x02;
|
||||||
|
internal const byte FlagSizeKnown = 0x04;
|
||||||
|
|
||||||
|
internal readonly ulong[] Frn; // 归一化 FRN(低 48 位记录号)
|
||||||
|
internal readonly ulong[] ParentFrn; // 归一化父目录 FRN
|
||||||
|
internal readonly long[] NameRef; // NamePool.Pack(偏移, 长度)
|
||||||
|
internal readonly long[] Size; // -1 = 未知(USN 降级模式)
|
||||||
|
internal readonly long[] ModifiedTicks; // UTC ticks;0 = 未知
|
||||||
|
internal readonly uint[] Attributes; // FILE_ATTRIBUTE_*
|
||||||
|
internal readonly byte[] Flags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 名字池与数组快照绑定在一起:查询只需抓取一次 store 引用就能得到一致的 (名字池, 数组, 条数)。
|
||||||
|
/// 关键:扩容(<see cref="Grow"/>)必须<b>复用同一个名字池</b> —— NameRef 里存的是池内偏移,
|
||||||
|
/// 换池等于让所有老记录的名字全部失效。
|
||||||
|
/// </summary>
|
||||||
|
internal readonly NamePool Names;
|
||||||
|
|
||||||
|
internal readonly int Capacity;
|
||||||
|
|
||||||
|
/// <summary>已发布条数。写入方必须在写完所有字段之后再自增它。</summary>
|
||||||
|
internal volatile int Count;
|
||||||
|
|
||||||
|
internal IndexStore(int capacity) : this(capacity, new NamePool())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private IndexStore(int capacity, NamePool names)
|
||||||
|
{
|
||||||
|
if (capacity < 16) capacity = 16;
|
||||||
|
Capacity = capacity;
|
||||||
|
Names = names;
|
||||||
|
Frn = new ulong[capacity];
|
||||||
|
ParentFrn = new ulong[capacity];
|
||||||
|
NameRef = new long[capacity];
|
||||||
|
Size = new long[capacity];
|
||||||
|
ModifiedTicks = new long[capacity];
|
||||||
|
Attributes = new uint[capacity];
|
||||||
|
Flags = new byte[capacity];
|
||||||
|
}
|
||||||
|
|
||||||
|
private IndexStore(IndexStore old, int capacity) : this(capacity, old.Names)
|
||||||
|
{
|
||||||
|
int n = old.Count;
|
||||||
|
Array.Copy(old.Frn, Frn, n);
|
||||||
|
Array.Copy(old.ParentFrn, ParentFrn, n);
|
||||||
|
Array.Copy(old.NameRef, NameRef, n);
|
||||||
|
Array.Copy(old.Size, Size, n);
|
||||||
|
Array.Copy(old.ModifiedTicks, ModifiedTicks, n);
|
||||||
|
Array.Copy(old.Attributes, Attributes, n);
|
||||||
|
Array.Copy(old.Flags, Flags, n);
|
||||||
|
Count = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>扩容(copy-on-write)。返回新快照,旧快照仍然可被并发查询安全使用。</summary>
|
||||||
|
internal static IndexStore Grow(IndexStore old, int minCapacity)
|
||||||
|
{
|
||||||
|
int capacity = old.Capacity;
|
||||||
|
while (capacity < minCapacity) capacity = capacity < 1024 ? capacity * 2 : capacity + (capacity >> 1);
|
||||||
|
return new IndexStore(old, capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool IsDeleted(int i) => (Flags[i] & FlagDeleted) != 0;
|
||||||
|
|
||||||
|
internal bool IsDirectory(int i) => (Flags[i] & FlagDirectory) != 0;
|
||||||
|
|
||||||
|
/// <summary>读名字(零分配)。</summary>
|
||||||
|
internal ReadOnlySpan<char> GetName(int i)
|
||||||
|
{
|
||||||
|
long nameRef = Volatile.Read(ref NameRef[i]);
|
||||||
|
return Names.Get(NamePool.UnpackOffset(nameRef), NamePool.UnpackLength(nameRef));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FRN(低 48 位记录号)→ 索引下标的映射。
|
||||||
|
///
|
||||||
|
/// 策略:构建期用普通 Dictionary 最快;构建结束后调用 <see cref="Optimize"/>,
|
||||||
|
/// 如果记录号足够密集(maxRecord <= 8 * count),就换成“记录号直接寻址”的 int[],
|
||||||
|
/// 100 万文件只占几 MB(而 Dictionary 要 30~40MB)。
|
||||||
|
/// 稀疏卷或监听期新增的越界记录号退回到 <see cref="ConcurrentDictionary{TKey,TValue}"/> 侧表。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FrnMap
|
||||||
|
{
|
||||||
|
private const int NoIndex = 0;
|
||||||
|
private const int DenseSlack = 8;
|
||||||
|
|
||||||
|
private Dictionary<ulong, int>? _buildMap;
|
||||||
|
private int[]? _dense; // 记录号 → 下标+1;0 表示不存在
|
||||||
|
private ConcurrentDictionary<ulong, int>? _sparse;
|
||||||
|
|
||||||
|
internal FrnMap(int estimatedCount)
|
||||||
|
{
|
||||||
|
_buildMap = new Dictionary<ulong, int>(Math.Max(16, estimatedCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal int Count => _buildMap?.Count ?? _sparse?.Count ?? _dense?.Length ?? 0;
|
||||||
|
|
||||||
|
/// <summary>构建期写入(单线程,无锁,最快)。</summary>
|
||||||
|
internal void AddBuild(ulong frn, int index)
|
||||||
|
{
|
||||||
|
_buildMap![UsnNative.NormalizeFrn(frn)] = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>构建完成后调用:把构建期的 Dictionary 压缩成密集数组或稀疏侧表。</summary>
|
||||||
|
internal void Optimize()
|
||||||
|
{
|
||||||
|
var map = _buildMap ?? throw new InvalidOperationException("FrnMap 已经压缩过。");
|
||||||
|
_buildMap = null;
|
||||||
|
|
||||||
|
ulong maxRecord = 0;
|
||||||
|
foreach (var key in map.Keys)
|
||||||
|
{
|
||||||
|
if (key > maxRecord) maxRecord = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密集数组代价 = (maxRecord+1)*4 字节;只要不超过 8 个记录号/条目的开销就值得。
|
||||||
|
if (map.Count > 0 && maxRecord <= (ulong)map.Count * DenseSlack)
|
||||||
|
{
|
||||||
|
var dense = new int[maxRecord + 1];
|
||||||
|
foreach (var (key, value) in map)
|
||||||
|
{
|
||||||
|
dense[key] = value + 1;
|
||||||
|
}
|
||||||
|
Volatile.Write(ref _dense, dense);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sparse = new ConcurrentDictionary<ulong, int>();
|
||||||
|
foreach (var (key, value) in map)
|
||||||
|
{
|
||||||
|
sparse[key] = value;
|
||||||
|
}
|
||||||
|
Volatile.Write(ref _sparse, sparse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>监听期写入(可能并发于查询,必须线程安全)。</summary>
|
||||||
|
internal void Set(ulong frn, int index)
|
||||||
|
{
|
||||||
|
ulong record = UsnNative.NormalizeFrn(frn);
|
||||||
|
var dense = Volatile.Read(ref _dense);
|
||||||
|
if (dense is not null && record < (ulong)dense.Length)
|
||||||
|
{
|
||||||
|
Volatile.Write(ref dense[record], index + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var sparse = _sparse;
|
||||||
|
if (sparse is null)
|
||||||
|
{
|
||||||
|
var created = new ConcurrentDictionary<ulong, int>();
|
||||||
|
sparse = Interlocked.CompareExchange(ref _sparse, created, null) ?? created;
|
||||||
|
}
|
||||||
|
sparse[record] = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>批量覆盖(重建索引时用,单线程)。</summary>
|
||||||
|
internal void SetUnsafe(ulong frn, int index)
|
||||||
|
{
|
||||||
|
ulong record = UsnNative.NormalizeFrn(frn);
|
||||||
|
var dense = _dense;
|
||||||
|
if (dense is not null && record < (ulong)dense.Length)
|
||||||
|
{
|
||||||
|
dense[record] = index + 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sparse![record] = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryGet(ulong frn, out int index)
|
||||||
|
{
|
||||||
|
ulong record = UsnNative.NormalizeFrn(frn);
|
||||||
|
var dense = Volatile.Read(ref _dense);
|
||||||
|
if (dense is not null && record < (ulong)dense.Length)
|
||||||
|
{
|
||||||
|
int slot = Volatile.Read(ref dense[record]);
|
||||||
|
if (slot == NoIndex)
|
||||||
|
{
|
||||||
|
index = -1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
index = slot - 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var sparse = Volatile.Read(ref _sparse);
|
||||||
|
if (sparse is not null && sparse.TryGetValue(record, out index)) return true;
|
||||||
|
index = -1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>NormizeFrn 的别名,便于调用点表达意图。</summary>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
internal static ulong Normalize(ulong frn) => UsnNative.NormalizeFrn(frn);
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Search.Usn;
|
||||||
|
|
||||||
|
/// <summary>从一条 $MFT 文件记录里解出来的关键字段。</summary>
|
||||||
|
internal struct MftRecordInfo
|
||||||
|
{
|
||||||
|
internal bool InUse;
|
||||||
|
internal bool IsDirectory;
|
||||||
|
internal long Size; // 数据实大小;目录或未找到 $DATA 时为 -1
|
||||||
|
internal long ModifiedFileTime; // $STANDARD_INFORMATION 偏移 8(LastDataChangeTime)
|
||||||
|
internal long CreatedFileTime; // $STANDARD_INFORMATION 偏移 0
|
||||||
|
internal uint Attributes;
|
||||||
|
internal ulong ParentRecordNumber; // $FILE_NAME 的父目录记录号(低 48 位)
|
||||||
|
internal int NameLength; // 字符数;-1 表示没有 $FILE_NAME
|
||||||
|
internal int NameRecordOffset; // 名字在记录内的字节偏移
|
||||||
|
internal byte NameNamespace; // 0=POSIX 1=Win32 2=DOS 3=Win32&DOS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 原始 $MFT 读取器:绕过 USN 日志,直接读卷上的 MFT 数据来拿“真实文件大小 / 精确时间戳 / 真实属性”。
|
||||||
|
///
|
||||||
|
/// 步骤:
|
||||||
|
/// 1) FSCTL_GET_NTFS_VOLUME_DATA 拿 BytesPerSector / BytesPerCluster / BytesPerFileRecordSegment / MftStartLcn;
|
||||||
|
/// 2) 直接按字节偏移读 MFT 的第 0 条记录($MFT 自身),做 fixup 修正后解析它的 $DATA(0x80) 属性 run list;
|
||||||
|
/// 3) 由 run list 得到 $MFT 在卷上的全部簇区间,之后按区间批量 ReadFile 并逐条做 fixup 修正 + 属性解析。
|
||||||
|
///
|
||||||
|
/// 权限:ReadFile 卷句柄需要 GENERIC_READ,即必须有管理员权限。拿不到时由调用方降级为纯 USN 索引。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MftReader
|
||||||
|
{
|
||||||
|
private const int ReadBlockSize = 4 * 1024 * 1024;
|
||||||
|
private const uint AttrStandardInformation = 0x10;
|
||||||
|
private const uint AttrFileName = 0x30;
|
||||||
|
private const uint AttrData = 0x80;
|
||||||
|
private const uint AttrEnd = 0xFFFFFFFF;
|
||||||
|
private const uint FileRecordSignature = 0x454C4946; // "FILE"
|
||||||
|
|
||||||
|
private readonly SafeFileHandle _volume;
|
||||||
|
private readonly int _bytesPerRecord;
|
||||||
|
private readonly int _bytesPerSector;
|
||||||
|
private readonly int _bytesPerCluster;
|
||||||
|
private readonly (long Start, long Length)[] _extents;
|
||||||
|
|
||||||
|
internal long MftValidDataLength { get; }
|
||||||
|
|
||||||
|
internal int BytesPerRecord => _bytesPerRecord;
|
||||||
|
|
||||||
|
internal int BytesPerSector => _bytesPerSector;
|
||||||
|
|
||||||
|
/// <summary>MFT 在卷上的簇区间(已按字节换算),可用于日志与自检。</summary>
|
||||||
|
internal IReadOnlyList<(long Start, long Length)> Extents => _extents;
|
||||||
|
|
||||||
|
private MftReader(SafeFileHandle volume, int bytesPerRecord, int bytesPerSector, int bytesPerCluster,
|
||||||
|
(long, long)[] extents, long mftValidDataLength)
|
||||||
|
{
|
||||||
|
_volume = volume;
|
||||||
|
_bytesPerRecord = bytesPerRecord;
|
||||||
|
_bytesPerSector = bytesPerSector;
|
||||||
|
_bytesPerCluster = bytesPerCluster;
|
||||||
|
_extents = extents;
|
||||||
|
MftValidDataLength = mftValidDataLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 尝试建立 MftReader。失败(非 NTFS、无权限、run list 解析不出来)返回 null 并通过 <paramref name="error"/> 说明原因。
|
||||||
|
/// </summary>
|
||||||
|
internal static MftReader? TryCreate(SafeFileHandle volume, in UsnNative.NtfsVolumeDataBuffer vd, out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
if (vd.BytesPerSector is < 256 or > 65536) { error = $"扇区尺寸异常({vd.BytesPerSector} 字节)"; return null; }
|
||||||
|
if (vd.BytesPerCluster is < 256 or > 8 * 1024 * 1024) { error = $"簇尺寸异常({vd.BytesPerCluster} 字节)"; return null; }
|
||||||
|
if (vd.BytesPerFileRecordSegment is < 256 or > 65536 || vd.BytesPerFileRecordSegment % vd.BytesPerSector != 0)
|
||||||
|
{
|
||||||
|
error = $"MFT 记录尺寸异常({vd.BytesPerFileRecordSegment} 字节)";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bytesPerSector = (int)vd.BytesPerSector;
|
||||||
|
int bytesPerCluster = (int)vd.BytesPerCluster;
|
||||||
|
int bytesPerRecord = (int)vd.BytesPerFileRecordSegment;
|
||||||
|
|
||||||
|
// ---- 1) 读 $MFT 自己的记录(记录号 0),位置 = MftStartLcn 个簇 ----
|
||||||
|
var record0 = new byte[bytesPerRecord];
|
||||||
|
long mftStartByte = vd.MftStartLcn * bytesPerCluster;
|
||||||
|
if (UsnNative.ReadAt(volume, record0, mftStartByte) != bytesPerRecord)
|
||||||
|
{
|
||||||
|
error = "无法读取 $MFT 的第一条记录(卷句柄缺少读数据权限,或磁盘未就绪)";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader = new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [], vd.MftValidDataLength);
|
||||||
|
if (!ApplyFixup(record0, bytesPerSector))
|
||||||
|
{
|
||||||
|
error = "$MFT 记录 0 的 fixup(USA) 校验失败";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2) 解析 $DATA(0x80) 的 run list,得到 MFT 在卷上的簇区间 ----
|
||||||
|
List<(long, long)> extents = [];
|
||||||
|
if (reader.TryReadDataRuns(record0, extents) && extents.Count > 0)
|
||||||
|
{
|
||||||
|
return new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [.. extents], vd.MftValidDataLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3) 降级:绝大多数卷上 $MFT 是连续的,直接按 MftStartLcn 连续读 ----
|
||||||
|
long needed = vd.MftValidDataLength > 0 ? vd.MftValidDataLength : vd.NumberSectors * bytesPerSector;
|
||||||
|
long span = (needed + bytesPerCluster - 1) / bytesPerCluster * bytesPerCluster;
|
||||||
|
extents.Clear();
|
||||||
|
extents.Add((mftStartByte, span));
|
||||||
|
error = "$MFT 的 run list 解析失败,已按“MFT 连续存放”的假设降级读取";
|
||||||
|
return new MftReader(volume, bytesPerRecord, bytesPerSector, bytesPerCluster, [.. extents], vd.MftValidDataLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ fixup / USA
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 应用 NTFS 的 fixup(Update Sequence Array)修正:
|
||||||
|
/// 每个扇区最后 2 个字节在磁盘上被换成了 USA 里的“更新序列号”,
|
||||||
|
/// 必须在解析前用 USA 中保存的真实值逐个还原,否则跨扇区的字段会是垃圾数据。
|
||||||
|
/// 返回 false 表示 USN 不匹配(记录在读取过程中被改写或已损坏),调用方应跳过该记录。
|
||||||
|
/// 设计成 static 是为了能被“合成记录”单元测试直接调用(无需真实卷句柄)。
|
||||||
|
/// </summary>
|
||||||
|
internal static bool ApplyFixup(Span<byte> record, int bytesPerSector)
|
||||||
|
{
|
||||||
|
if (record.Length < 8 || bytesPerSector <= 0) return false;
|
||||||
|
int usaOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[4..]);
|
||||||
|
int usaCount = BinaryPrimitives.ReadUInt16LittleEndian(record[6..]);
|
||||||
|
if (usaCount < 1) return false;
|
||||||
|
if (usaOffset + usaCount * 2 > record.Length) return false;
|
||||||
|
// USA 覆盖的扇区数必须与记录尺寸一致
|
||||||
|
if ((usaCount - 1) * bytesPerSector > record.Length) return false;
|
||||||
|
|
||||||
|
ushort usn = BinaryPrimitives.ReadUInt16LittleEndian(record[usaOffset..]);
|
||||||
|
for (int i = 1; i < usaCount; i++)
|
||||||
|
{
|
||||||
|
int pos = i * bytesPerSector - 2;
|
||||||
|
if (pos + 2 > record.Length) return false;
|
||||||
|
if (BinaryPrimitives.ReadUInt16LittleEndian(record[pos..]) != usn) return false;
|
||||||
|
ushort real = BinaryPrimitives.ReadUInt16LittleEndian(record[(usaOffset + i * 2)..]);
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(record[pos..], real);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ 属性解析
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从记录里读取 $DATA(0x80) 的 mapping pairs(run list),换算成卷内字节区间。
|
||||||
|
/// 这里刻意手写属性链循环而不用委托回调:该路径在构建期会被调用百万次,闭包分配不可接受。
|
||||||
|
/// </summary>
|
||||||
|
private bool TryReadDataRuns(ReadOnlySpan<byte> record, List<(long, long)> extents)
|
||||||
|
{
|
||||||
|
if (record.Length < 48) return false;
|
||||||
|
int offset = BinaryPrimitives.ReadUInt16LittleEndian(record[20..]);
|
||||||
|
int guard = 0;
|
||||||
|
while (offset >= 24 && offset + 8 <= record.Length && guard++ < 1024)
|
||||||
|
{
|
||||||
|
uint type = BinaryPrimitives.ReadUInt32LittleEndian(record[offset..]);
|
||||||
|
if (type == AttrEnd) break;
|
||||||
|
uint length = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 4)..]);
|
||||||
|
if (length < 24 || offset + length > record.Length) return false;
|
||||||
|
bool nonResident = record[offset + 8] != 0;
|
||||||
|
if (type != AttrData || !nonResident)
|
||||||
|
{
|
||||||
|
offset += (int)length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int runOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 32)..]);
|
||||||
|
int end = offset + (int)length;
|
||||||
|
if (runOffset <= 0 || offset + runOffset >= end) return false;
|
||||||
|
|
||||||
|
long lcn = 0;
|
||||||
|
int p = offset + runOffset;
|
||||||
|
while (p < end)
|
||||||
|
{
|
||||||
|
int header = record[p++];
|
||||||
|
if (header == 0) break;
|
||||||
|
int lenSize = header & 0x0F;
|
||||||
|
int offSize = header >> 4;
|
||||||
|
if (lenSize == 0 || p + lenSize + offSize > end) break;
|
||||||
|
|
||||||
|
long runLength = 0;
|
||||||
|
for (int i = 0; i < lenSize; i++) runLength |= (long)record[p + i] << (8 * i);
|
||||||
|
p += lenSize;
|
||||||
|
|
||||||
|
long delta = 0;
|
||||||
|
bool sparse = offSize == 0;
|
||||||
|
if (!sparse)
|
||||||
|
{
|
||||||
|
// 偏移字段是相对上一个 LCN 的“有符号”小端整数,必须做符号扩展
|
||||||
|
for (int i = 0; i < offSize; i++) delta |= (long)record[p + i] << (8 * i);
|
||||||
|
long signBit = 1L << (8 * offSize - 1);
|
||||||
|
if ((delta & signBit) != 0) delta -= signBit << 1;
|
||||||
|
p += offSize;
|
||||||
|
lcn += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稀疏区段(offset 字段宽度为 0)不占物理簇,直接跳过
|
||||||
|
if (runLength > 0 && !sparse) extents.Add((lcn * _bytesPerCluster, runLength * _bytesPerCluster));
|
||||||
|
}
|
||||||
|
return extents.Count > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解析一条 MFT 记录的 $STANDARD_INFORMATION(0x10) / $FILE_NAME(0x30) / $DATA(0x80)。</summary>
|
||||||
|
internal static bool TryParseRecord(Span<byte> record, int bytesPerSector, out MftRecordInfo info)
|
||||||
|
{
|
||||||
|
info = default;
|
||||||
|
info.Size = -1;
|
||||||
|
info.NameLength = -1;
|
||||||
|
if (record.Length < 56) return false;
|
||||||
|
if (BinaryPrimitives.ReadUInt32LittleEndian(record) != FileRecordSignature) return false;
|
||||||
|
if (!ApplyFixup(record, bytesPerSector)) return false;
|
||||||
|
|
||||||
|
ushort flags = BinaryPrimitives.ReadUInt16LittleEndian(record[22..]);
|
||||||
|
info.InUse = (flags & 0x0001) != 0;
|
||||||
|
info.IsDirectory = (flags & 0x0002) != 0;
|
||||||
|
|
||||||
|
int bestNamespace = -1;
|
||||||
|
int offset = BinaryPrimitives.ReadUInt16LittleEndian(record[20..]);
|
||||||
|
int guard = 0;
|
||||||
|
while (offset >= 24 && offset + 8 <= record.Length && guard++ < 1024)
|
||||||
|
{
|
||||||
|
uint type = BinaryPrimitives.ReadUInt32LittleEndian(record[offset..]);
|
||||||
|
if (type == AttrEnd) break;
|
||||||
|
uint length = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 4)..]);
|
||||||
|
if (length < 24 || offset + length > record.Length) break;
|
||||||
|
bool nonResident = record[offset + 8] != 0;
|
||||||
|
|
||||||
|
if (!nonResident && type == AttrStandardInformation)
|
||||||
|
{
|
||||||
|
int valueOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 20)..]);
|
||||||
|
int valueLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]);
|
||||||
|
int v = offset + valueOffset;
|
||||||
|
if (valueLength >= 36 && v + 36 <= record.Length)
|
||||||
|
{
|
||||||
|
info.CreatedFileTime = BinaryPrimitives.ReadInt64LittleEndian(record[v..]);
|
||||||
|
info.ModifiedFileTime = BinaryPrimitives.ReadInt64LittleEndian(record[(v + 8)..]);
|
||||||
|
info.Attributes = BinaryPrimitives.ReadUInt32LittleEndian(record[(v + 32)..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!nonResident && type == AttrFileName)
|
||||||
|
{
|
||||||
|
int valueOffset = BinaryPrimitives.ReadUInt16LittleEndian(record[(offset + 20)..]);
|
||||||
|
int valueLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]);
|
||||||
|
int v = offset + valueOffset;
|
||||||
|
if (valueLength >= 66 && v + 66 <= record.Length)
|
||||||
|
{
|
||||||
|
int nameLength = record[v + 64];
|
||||||
|
int nameSpace = record[v + 65];
|
||||||
|
// 同一文件可能有多个 $FILE_NAME(硬链接 / 8.3 短名):优先 Win32 系列,跳过纯 DOS 名
|
||||||
|
bool better = bestNamespace < 0 || (bestNamespace == 2 && nameSpace != 2);
|
||||||
|
if (better && v + 66 + nameLength * 2 <= record.Length)
|
||||||
|
{
|
||||||
|
bestNamespace = nameSpace;
|
||||||
|
info.ParentRecordNumber = BinaryPrimitives.ReadUInt64LittleEndian(record[v..]) & UsnNative.RecordNumberMask;
|
||||||
|
info.NameLength = nameLength;
|
||||||
|
info.NameRecordOffset = v + 66;
|
||||||
|
info.NameNamespace = (byte)nameSpace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type == AttrData)
|
||||||
|
{
|
||||||
|
if (nonResident)
|
||||||
|
{
|
||||||
|
if (offset + 56 <= record.Length)
|
||||||
|
info.Size = BinaryPrimitives.ReadInt64LittleEndian(record[(offset + 48)..]); // RealSize
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
info.Size = BinaryPrimitives.ReadUInt32LittleEndian(record[(offset + 16)..]); // 驻留数据的大小 = 值长度
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += (int)length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.IsDirectory) info.Size = -1; // 目录没有“文件大小”概念,统一记为未知
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ 批量枚举
|
||||||
|
|
||||||
|
internal delegate void RecordVisitor(ulong recordNumber, Span<byte> record);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按区间流式读取整个 MFT,逐条回调(缓冲区复用,不产生 per-record 分配)。
|
||||||
|
/// <paramref name="maxByteOffset"/> 一般传 MftValidDataLength。
|
||||||
|
/// </summary>
|
||||||
|
internal void Enumerate(RecordVisitor visitor, long maxByteOffset, Action<long>? onBytesRead, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = new byte[Math.Max(ReadBlockSize, _bytesPerRecord * 2)];
|
||||||
|
long consumed = 0; // 相对 MFT 起点的字节数
|
||||||
|
|
||||||
|
foreach (var (start, length) in _extents)
|
||||||
|
{
|
||||||
|
long extentRead = 0;
|
||||||
|
while (extentRead + _bytesPerRecord <= length)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (maxByteOffset > 0 && consumed >= maxByteOffset) return;
|
||||||
|
|
||||||
|
long remaining = Math.Min(length - extentRead, maxByteOffset > 0 ? maxByteOffset - consumed : long.MaxValue);
|
||||||
|
if (remaining < _bytesPerRecord) return;
|
||||||
|
|
||||||
|
// 让每次读取边界都落在整条记录上,避免记录被拆到两次读里
|
||||||
|
int want = (int)Math.Min(buffer.Length, remaining);
|
||||||
|
want -= want % _bytesPerRecord;
|
||||||
|
if (want < _bytesPerRecord) want = _bytesPerRecord;
|
||||||
|
|
||||||
|
int got = UsnNative.ReadAt(_volume, buffer.AsSpan(0, want), start + extentRead);
|
||||||
|
if (got < _bytesPerRecord) return;
|
||||||
|
got -= got % _bytesPerRecord;
|
||||||
|
|
||||||
|
var span = buffer.AsSpan(0, got);
|
||||||
|
for (int off = 0; off + _bytesPerRecord <= got; off += _bytesPerRecord)
|
||||||
|
{
|
||||||
|
ulong recordNumber = (ulong)((consumed + off) / _bytesPerRecord);
|
||||||
|
visitor(recordNumber, span.Slice(off, _bytesPerRecord));
|
||||||
|
}
|
||||||
|
|
||||||
|
extentRead += got;
|
||||||
|
consumed += got;
|
||||||
|
onBytesRead?.Invoke(consumed);
|
||||||
|
if (got < want) return;
|
||||||
|
}
|
||||||
|
if (maxByteOffset > 0 && consumed >= maxByteOffset) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按记录号读一条 MFT 记录(走 run list 换算物理偏移)。用于增量监听时刷新单个文件的大小/时间。
|
||||||
|
/// </summary>
|
||||||
|
internal bool TryReadRecord(ulong recordNumber, Span<byte> destination)
|
||||||
|
{
|
||||||
|
if (destination.Length < _bytesPerRecord) return false;
|
||||||
|
long mftByteOffset = (long)recordNumber * _bytesPerRecord;
|
||||||
|
foreach (var (start, length) in _extents)
|
||||||
|
{
|
||||||
|
if (mftByteOffset < length)
|
||||||
|
{
|
||||||
|
int got = UsnNative.ReadAt(_volume, destination[.._bytesPerRecord], start + mftByteOffset);
|
||||||
|
return got == _bytesPerRecord;
|
||||||
|
}
|
||||||
|
mftByteOffset -= length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
namespace FluidExplorer.Services.Search.Usn;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 紧凑文件名池 —— 索引“快且省内存”的关键之一。
|
||||||
|
///
|
||||||
|
/// 所有文件名以 UTF-16 连续存放在若干 <b>定长块</b>(每块 1<<20 个字符 = 2MB)里,
|
||||||
|
/// 整个索引里不会为文件名产生任何 string 对象;查询时直接在这块内存上取 ReadOnlySpan<char>。
|
||||||
|
///
|
||||||
|
/// 偏移编码:<c>(chunkIndex << 20) | offsetInChunk</c>,单个 int 即可寻址 2G 字符。
|
||||||
|
/// 因为块大小固定为 1<<20,而 NTFS 单个文件名最长 255 字符,
|
||||||
|
/// 所以一个名字永远不会跨越两个块 —— 取值时无需拼接。
|
||||||
|
///
|
||||||
|
/// 线程安全:块数组一次性预分配(只写指针不扩容),块本身写入后用 <see cref="Volatile.Write{T}(ref T, T)"/> 发布。
|
||||||
|
/// 查询线程只会读到“已经完整写入”的块;配合索引条数的发布顺序(先写数据、后写 Count),读侧无需加锁。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NamePool
|
||||||
|
{
|
||||||
|
internal const int ChunkShift = 20;
|
||||||
|
internal const int ChunkSize = 1 << ChunkShift;
|
||||||
|
internal const int ChunkMask = ChunkSize - 1;
|
||||||
|
/// <summary>NameRef 只用 8 位存长度,NTFS 文件名最长 255 字符,正好用满。</summary>
|
||||||
|
internal const int MaxNameLength = 255;
|
||||||
|
private const int MaxChunks = 2048; // 2048 * 1M = 2G 字符上限
|
||||||
|
|
||||||
|
private readonly char[][] _chunks = new char[MaxChunks][];
|
||||||
|
private int _chunkCount;
|
||||||
|
private int _fill; // 当前块已使用字符数
|
||||||
|
private int _publishedChars;
|
||||||
|
|
||||||
|
internal NamePool()
|
||||||
|
{
|
||||||
|
AddChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>池中已发布的字符总数(约等于所有名字长度之和)。</summary>
|
||||||
|
internal int TotalChars => _publishedChars;
|
||||||
|
|
||||||
|
/// <summary>写入一个名字,返回它的池内偏移。空名字返回 0(配合 NameRef 的长度字段仍然无歧义)。</summary>
|
||||||
|
internal int Add(ReadOnlySpan<char> name)
|
||||||
|
{
|
||||||
|
if (name.Length == 0) return 0;
|
||||||
|
if (name.Length > ChunkSize) name = name[..ChunkSize];
|
||||||
|
|
||||||
|
if (_fill + name.Length > ChunkSize) AddChunk();
|
||||||
|
|
||||||
|
int chunkIndex = _chunkCount - 1;
|
||||||
|
int offset = (chunkIndex << ChunkShift) | _fill;
|
||||||
|
name.CopyTo(_chunks[chunkIndex].AsSpan(_fill));
|
||||||
|
_fill += name.Length;
|
||||||
|
_publishedChars = (chunkIndex << ChunkShift) + _fill;
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>取回名字。参数非法时返回空 span(绝不抛异常,读侧可能并发读到正在更新的旧值)。</summary>
|
||||||
|
internal ReadOnlySpan<char> Get(int offset, int length)
|
||||||
|
{
|
||||||
|
if (length <= 0 || offset < 0) return default;
|
||||||
|
int chunkIndex = offset >> ChunkShift;
|
||||||
|
int inChunk = offset & ChunkMask;
|
||||||
|
if (chunkIndex >= _chunkCount) return default;
|
||||||
|
var chunk = Volatile.Read(ref _chunks[chunkIndex]);
|
||||||
|
if (chunk is null || inChunk + length > ChunkSize) return default;
|
||||||
|
return chunk.AsSpan(inChunk, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddChunk()
|
||||||
|
{
|
||||||
|
if (_chunkCount >= MaxChunks) throw new InvalidOperationException("文件名池已达到容量上限(2G 字符)。");
|
||||||
|
var chunk = new char[ChunkSize];
|
||||||
|
Volatile.Write(ref _chunks[_chunkCount], chunk);
|
||||||
|
_chunkCount++;
|
||||||
|
_fill = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- NameRef 打包
|
||||||
|
|
||||||
|
/// <summary>把 (偏移, 长度) 打包进一个 long:低 8 位是长度,高 56 位是偏移。</summary>
|
||||||
|
internal static long Pack(int offset, int length) => ((long)offset << 8) | (uint)(length & 0xFF);
|
||||||
|
|
||||||
|
internal static int UnpackOffset(long nameRef) => (int)(nameRef >> 8);
|
||||||
|
|
||||||
|
internal static int UnpackLength(long nameRef) => (int)(nameRef & 0xFF);
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Search.Usn;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// USN 日志 / NTFS 卷所需的最小 P/Invoke 与结构体集合。
|
||||||
|
///
|
||||||
|
/// 布局约定:所有 struct 均为 <see cref="LayoutKind.Sequential"/>,默认采用 x64 自然对齐;
|
||||||
|
/// 字段顺序与偏移和 Windows SDK 的 winioctl.h / ntifs.h 完全一致。
|
||||||
|
/// 每个结构体后面都标注了实测字节大小,便于对照 <see cref="Marshal.SizeOf{T}()"/> 校验。
|
||||||
|
/// </summary>
|
||||||
|
internal static class UsnNative
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------- 访问权限 / 打开方式
|
||||||
|
|
||||||
|
internal const uint GENERIC_READ = 0x80000000;
|
||||||
|
internal const uint GENERIC_WRITE = 0x40000000;
|
||||||
|
internal const uint FILE_READ_DATA = 0x0001;
|
||||||
|
internal const uint FILE_READ_ATTRIBUTES = 0x0080;
|
||||||
|
internal const uint FILE_LIST_DIRECTORY = 0x0001;
|
||||||
|
|
||||||
|
internal const uint FILE_SHARE_READ = 0x00000001;
|
||||||
|
internal const uint FILE_SHARE_WRITE = 0x00000002;
|
||||||
|
internal const uint FILE_SHARE_DELETE = 0x00000004;
|
||||||
|
|
||||||
|
internal const uint OPEN_EXISTING = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开 <c>\\.\C:</c> 时用的 dwFlagsAndAttributes。
|
||||||
|
/// 参考实现(Everything)用 FILE_ATTRIBUTE_READONLY:传 FILE_ATTRIBUTE_NORMAL 在部分环境下会开不了卷句柄。
|
||||||
|
/// </summary>
|
||||||
|
internal const uint FILE_ATTRIBUTE_READONLY = 0x00000001;
|
||||||
|
|
||||||
|
/// <summary>开目录句柄必须带 FILE_FLAG_BACKUP_SEMANTICS,否则 CreateFile 会失败。</summary>
|
||||||
|
internal const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
||||||
|
|
||||||
|
/// <summary>顺序扫描提示:读 MFT 时对缓存友好(读一次不再复用)。</summary>
|
||||||
|
internal const uint FILE_FLAG_SEQUENTIAL_SCAN = 0x08000000;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 控制码 CTL_CODE(FILE_DEVICE_FILE_SYSTEM=0x09, ...)
|
||||||
|
|
||||||
|
internal const uint FSCTL_ENUM_USN_DATA = 0x000900B3;
|
||||||
|
internal const uint FSCTL_READ_USN_JOURNAL = 0x000900BB;
|
||||||
|
internal const uint FSCTL_QUERY_USN_JOURNAL = 0x000900F4;
|
||||||
|
internal const uint FSCTL_CREATE_USN_JOURNAL = 0x000900E7;
|
||||||
|
internal const uint FSCTL_DELETE_USN_JOURNAL = 0x000900F8;
|
||||||
|
internal const uint FSCTL_GET_NTFS_VOLUME_DATA = 0x00090064;
|
||||||
|
internal const uint FSCTL_GET_NTFS_FILE_RECORD = 0x00090068;
|
||||||
|
|
||||||
|
/// <summary>FSCTL_DELETE_USN_JOURNAL 的 DeleteFlags:真正删除日志。</summary>
|
||||||
|
internal const uint USN_DELETE_FLAG_DELETE = 0x00000001;
|
||||||
|
internal const uint USN_DELETE_FLAG_DO_NOT_DELETE = 0x00000000;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- ReadDirectoryChangesW(USN 日志不可用时的回退监听)
|
||||||
|
|
||||||
|
internal const uint FILE_NOTIFY_CHANGE_FILE_NAME = 0x00000001;
|
||||||
|
internal const uint FILE_NOTIFY_CHANGE_DIR_NAME = 0x00000002;
|
||||||
|
internal const uint FILE_NOTIFY_CHANGE_ATTRIBUTES = 0x00000004;
|
||||||
|
internal const uint FILE_NOTIFY_CHANGE_SIZE = 0x00000008;
|
||||||
|
internal const uint FILE_NOTIFY_CHANGE_LAST_WRITE = 0x00000010;
|
||||||
|
|
||||||
|
internal const uint FILE_ACTION_ADDED = 0x00000001;
|
||||||
|
internal const uint FILE_ACTION_REMOVED = 0x00000002;
|
||||||
|
internal const uint FILE_ACTION_MODIFIED = 0x00000003;
|
||||||
|
internal const uint FILE_ACTION_RENAMED_OLD_NAME = 0x00000004;
|
||||||
|
internal const uint FILE_ACTION_RENAMED_NEW_NAME = 0x00000005;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Win32 错误码
|
||||||
|
|
||||||
|
internal const int ERROR_INVALID_FUNCTION = 1;
|
||||||
|
internal const int ERROR_ACCESS_DENIED = 5;
|
||||||
|
internal const int ERROR_INVALID_HANDLE = 6;
|
||||||
|
internal const int ERROR_NOT_READY = 21;
|
||||||
|
internal const int ERROR_HANDLE_EOF = 38;
|
||||||
|
internal const int ERROR_NOT_SUPPORTED = 50;
|
||||||
|
internal const int ERROR_INVALID_PARAMETER = 87;
|
||||||
|
internal const int ERROR_MORE_DATA = 234;
|
||||||
|
internal const int ERROR_OPERATION_ABORTED = 995;
|
||||||
|
internal const int ERROR_NOTIFY_ENUM_DIR = 1022;
|
||||||
|
internal const int ERROR_JOURNAL_DELETE_IN_PROGRESS = 1178;
|
||||||
|
internal const int ERROR_JOURNAL_NOT_ACTIVE = 1179;
|
||||||
|
internal const int ERROR_JOURNAL_ENTRY_DELETED = 1181;
|
||||||
|
internal const int ERROR_CANCELLED = 1223;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 文件属性
|
||||||
|
|
||||||
|
internal const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- USN 变更原因
|
||||||
|
|
||||||
|
internal const uint USN_REASON_DATA_OVERWRITE = 0x00000001;
|
||||||
|
internal const uint USN_REASON_DATA_EXTEND = 0x00000002;
|
||||||
|
internal const uint USN_REASON_DATA_TRUNCATION = 0x00000004;
|
||||||
|
internal const uint USN_REASON_NAMED_DATA_OVERWRITE = 0x00000010;
|
||||||
|
internal const uint USN_REASON_NAMED_DATA_EXTEND = 0x00000020;
|
||||||
|
internal const uint USN_REASON_NAMED_DATA_TRUNCATION = 0x00000040;
|
||||||
|
internal const uint USN_REASON_FILE_CREATE = 0x00000100;
|
||||||
|
internal const uint USN_REASON_FILE_DELETE = 0x00000200;
|
||||||
|
internal const uint USN_REASON_EA_CHANGE = 0x00000400;
|
||||||
|
internal const uint USN_REASON_SECURITY_CHANGE = 0x00000800;
|
||||||
|
internal const uint USN_REASON_RENAME_OLD_NAME = 0x00001000;
|
||||||
|
internal const uint USN_REASON_RENAME_NEW_NAME = 0x00002000;
|
||||||
|
internal const uint USN_REASON_INDEXABLE_CHANGE = 0x00004000;
|
||||||
|
internal const uint USN_REASON_BASIC_INFO_CHANGE = 0x00008000;
|
||||||
|
internal const uint USN_REASON_HARD_LINK_CHANGE = 0x00010000;
|
||||||
|
internal const uint USN_REASON_COMPRESSION_CHANGE = 0x00020000;
|
||||||
|
internal const uint USN_REASON_ENCRYPTION_CHANGE = 0x00040000;
|
||||||
|
internal const uint USN_REASON_OBJECT_ID_CHANGE = 0x00080000;
|
||||||
|
internal const uint USN_REASON_REPARSE_POINT_CHANGE = 0x00100000;
|
||||||
|
internal const uint USN_REASON_STREAM_CHANGE = 0x00200000;
|
||||||
|
internal const uint USN_REASON_CLOSE = 0x80000000;
|
||||||
|
|
||||||
|
internal const uint USN_REASON_ANY = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 线程访问权限(CancelSynchronousIo 需要 THREAD_TERMINATE)
|
||||||
|
|
||||||
|
internal const uint THREAD_TERMINATE = 0x0001;
|
||||||
|
internal static readonly IntPtr INVALID_HANDLE_VALUE = new(-1);
|
||||||
|
|
||||||
|
// ================================================================ 结构体
|
||||||
|
|
||||||
|
/// <summary>MFT_ENUM_DATA_V0 —— FSCTL_ENUM_USN_DATA 的输入。x64 大小 24。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct MftEnumDataV0
|
||||||
|
{
|
||||||
|
internal ulong StartFileReferenceNumber; // 0
|
||||||
|
internal long LowUsn; // 8 枚举时用 0
|
||||||
|
internal long HighUsn; // 16 枚举时用 long.MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>USN_JOURNAL_DATA_V0 —— FSCTL_QUERY_USN_JOURNAL 的输出。x64 大小 56。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct UsnJournalDataV0
|
||||||
|
{
|
||||||
|
internal ulong UsnJournalID; // 0
|
||||||
|
internal long FirstUsn; // 8
|
||||||
|
internal long NextUsn; // 16
|
||||||
|
internal long LowestValidUsn; // 24
|
||||||
|
internal long MaxUsn; // 32
|
||||||
|
internal ulong MaximumSize; // 40
|
||||||
|
internal ulong AllocationDelta; // 48
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>READ_USN_JOURNAL_DATA_V0 —— FSCTL_READ_USN_JOURNAL 的输入。x64 大小 40。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct ReadUsnJournalDataV0
|
||||||
|
{
|
||||||
|
internal long StartUsn; // 0
|
||||||
|
internal uint ReasonMask; // 8
|
||||||
|
internal uint ReturnOnlyOnClose; // 12
|
||||||
|
internal ulong Timeout; // 16 100ns 单位;0 = 无限等待
|
||||||
|
internal ulong BytesToWaitFor; // 24 攒够这么多字节再返回(减少唤醒次数)
|
||||||
|
internal ulong UsnJournalID; // 32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// USN_RECORD_V2 的固定头部(不含变长文件名)。
|
||||||
|
///
|
||||||
|
/// 字段偏移与 Windows SDK 完全一致(x64):RecordLength@0、MajorVersion@4、FRN@8、
|
||||||
|
/// ParentFrn@16、Usn@24、TimeStamp@32、Reason@40、SourceInfo@44、SecurityId@48、
|
||||||
|
/// FileAttributes@52、FileNameLength@56、FileNameOffset@58;变长文件名紧跟在第 60 字节之后。
|
||||||
|
///
|
||||||
|
/// 显式 Pack=4 的原因:默认 8 字节对齐会把 sizeof 从 60 凑成 64(尾部补 4 字节),
|
||||||
|
/// 字段偏移虽然不变,但用 sizeof(T) 做缓冲边界判断会凭空多要求 4 字节;
|
||||||
|
/// Pack=4 下偏移完全不变、sizeof 恰好 60,与磁盘上的紧凑布局严格一致。
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
||||||
|
internal struct UsnRecordV2
|
||||||
|
{
|
||||||
|
internal uint RecordLength; // 0
|
||||||
|
internal ushort MajorVersion; // 4 必须 == 2
|
||||||
|
internal ushort MinorVersion; // 6
|
||||||
|
internal ulong FileReferenceNumber; // 8 高 16 位是序列号,低 48 位是 MFT 记录号
|
||||||
|
internal ulong ParentFileReferenceNumber; // 16 同上
|
||||||
|
internal long Usn; // 24
|
||||||
|
internal long TimeStamp; // 32 FILETIME(100ns since 1601)
|
||||||
|
internal uint Reason; // 40
|
||||||
|
internal uint SourceInfo; // 44
|
||||||
|
internal uint SecurityId; // 48
|
||||||
|
internal uint FileAttributes; // 52
|
||||||
|
internal ushort FileNameLength; // 56 字节数,非字符数
|
||||||
|
internal ushort FileNameOffset; // 58 相对记录起始的字节偏移
|
||||||
|
|
||||||
|
internal const int Size = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>NTFS_VOLUME_DATA_BUFFER —— FSCTL_GET_NTFS_VOLUME_DATA 的输出。x64 大小 96。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct NtfsVolumeDataBuffer
|
||||||
|
{
|
||||||
|
internal long VolumeSerialNumber; // 0
|
||||||
|
internal long NumberSectors; // 8
|
||||||
|
internal long TotalClusters; // 16
|
||||||
|
internal long FreeClusters; // 24
|
||||||
|
internal long TotalReserved; // 32
|
||||||
|
internal uint BytesPerSector; // 40
|
||||||
|
internal uint BytesPerCluster; // 44
|
||||||
|
internal uint BytesPerFileRecordSegment; // 48
|
||||||
|
internal uint ClustersPerFileRecordSegment; // 52
|
||||||
|
internal long MftValidDataLength; // 56
|
||||||
|
internal long MftStartLcn; // 64
|
||||||
|
internal long Mft2StartLcn; // 72
|
||||||
|
internal long MftZoneStart; // 80
|
||||||
|
internal long MftZoneEnd; // 88
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>NTFS_FILE_RECORD_INPUT_BUFFER —— FSCTL_GET_NTFS_FILE_RECORD 的输入。x64 大小 8。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct NtfsFileRecordInputBuffer
|
||||||
|
{
|
||||||
|
internal ulong FileReferenceNumber; // 只取低 48 位记录号
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>NTFS_FILE_RECORD_OUTPUT_BUFFER 的固定头。x64 大小 12(后面紧跟变长记录)。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct NtfsFileRecordOutputBuffer
|
||||||
|
{
|
||||||
|
internal ulong FileReferenceNumber; // 0
|
||||||
|
internal uint FileRecordLength; // 8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FSCTL_CREATE_USN_JOURNAL 的输入。x64 大小 16;两个字段都为 0 = 使用系统默认值。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct CreateUsnJournalData
|
||||||
|
{
|
||||||
|
internal ulong MaximumSize; // 0 = 系统默认
|
||||||
|
internal ulong AllocationDelta; // 0 = 系统默认
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FSCTL_DELETE_USN_JOURNAL 的输入。x64 大小 16(ulong + DWORD + 对齐填充)。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct DeleteUsnJournalData
|
||||||
|
{
|
||||||
|
internal ulong UsnJournalID;
|
||||||
|
internal uint DeleteFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FILETIME 的精确布局:两个 DWORD。
|
||||||
|
/// 刻意不用 long —— 在 LayoutKind.Sequential 下 long 会带来 8 字节对齐,
|
||||||
|
/// 从而把 BY_HANDLE_FILE_INFORMATION 的后续字段全部顶偏。
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct FileTimeValue
|
||||||
|
{
|
||||||
|
internal uint LowDateTime;
|
||||||
|
internal uint HighDateTime;
|
||||||
|
|
||||||
|
internal readonly long ToInt64() => ((long)HighDateTime << 32) | LowDateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BY_HANDLE_FILE_INFORMATION(GetFileInformationByHandle 的输出)。x64 大小 52。
|
||||||
|
/// 其中 FileIndexHigh/Low 合起来就是文件的 64 位 FRN(低 48 位记录号 + 高 16 位序列号),
|
||||||
|
/// 与 USN_RECORD_V2 里的 FileReferenceNumber 口径一致 —— 这是 RDCW 回退路径能定位索引条目的关键。
|
||||||
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct ByHandleFileInformation
|
||||||
|
{
|
||||||
|
internal uint FileAttributes; // 0
|
||||||
|
internal FileTimeValue CreationTime; // 4
|
||||||
|
internal FileTimeValue LastAccessTime; // 12
|
||||||
|
internal FileTimeValue LastWriteTime; // 20
|
||||||
|
internal uint VolumeSerialNumber; // 28
|
||||||
|
internal uint FileSizeHigh; // 32
|
||||||
|
internal uint FileSizeLow; // 36
|
||||||
|
internal uint NumberOfLinks; // 40
|
||||||
|
internal uint FileIndexHigh; // 44
|
||||||
|
internal uint FileIndexLow; // 48
|
||||||
|
|
||||||
|
internal readonly ulong FileIndex => ((ulong)FileIndexHigh << 32) | FileIndexLow;
|
||||||
|
|
||||||
|
internal readonly long FileSize => ((long)FileSizeHigh << 32) | FileSizeLow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FILE_NOTIFY_INFORMATION 的固定头(变长文件名紧跟其后,UTF-16,不以 NUL 结尾)。</summary>
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct FileNotifyInformation
|
||||||
|
{
|
||||||
|
internal uint NextEntryOffset; // 0
|
||||||
|
internal uint Action; // 4
|
||||||
|
internal uint FileNameLength; // 8,字节数
|
||||||
|
// WCHAR FileName[1]; // 12
|
||||||
|
internal const int HeaderSize = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ P/Invoke
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode, ExactSpelling = true)]
|
||||||
|
internal static extern SafeFileHandle CreateFileW(
|
||||||
|
string lpFileName,
|
||||||
|
uint dwDesiredAccess,
|
||||||
|
uint dwShareMode,
|
||||||
|
IntPtr lpSecurityAttributes,
|
||||||
|
uint dwCreationDisposition,
|
||||||
|
uint dwFlagsAndAttributes,
|
||||||
|
IntPtr hTemplateFile);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern unsafe bool DeviceIoControl(
|
||||||
|
SafeFileHandle hDevice,
|
||||||
|
uint dwIoControlCode,
|
||||||
|
void* lpInBuffer,
|
||||||
|
uint nInBufferSize,
|
||||||
|
void* lpOutBuffer,
|
||||||
|
uint nOutBufferSize,
|
||||||
|
out uint lpBytesReturned,
|
||||||
|
IntPtr lpOverlapped);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern unsafe bool ReadFile(
|
||||||
|
SafeFileHandle hFile,
|
||||||
|
void* lpBuffer,
|
||||||
|
uint nNumberOfBytesToRead,
|
||||||
|
out uint lpNumberOfBytesRead,
|
||||||
|
IntPtr lpOverlapped);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern unsafe bool SetFilePointerEx(
|
||||||
|
SafeFileHandle hFile,
|
||||||
|
long liDistanceToMove,
|
||||||
|
out long lpNewFilePointer,
|
||||||
|
uint dwMoveMethod);
|
||||||
|
|
||||||
|
internal const uint FILE_BEGIN = 0;
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern bool CloseHandle(IntPtr hObject);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern IntPtr OpenThread(uint dwDesiredAccess, bool bInheritHandle, uint dwThreadId);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern uint GetCurrentThreadId();
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern bool CancelSynchronousIo(IntPtr hThread);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern bool GetFileInformationByHandle(SafeFileHandle hFile, out ByHandleFileInformation lpFileInformation);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 递归监听目录变化。lpOverlapped == NULL 时是同步阻塞调用(靠 CancelSynchronousIo 打断)。
|
||||||
|
/// 返回 TRUE 且 bytesReturned == 0 表示变更缓冲区溢出(ERROR_NOTIFY_ENUM_DIR),期间的事件已丢失。
|
||||||
|
/// </summary>
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
internal static extern unsafe bool ReadDirectoryChangesW(
|
||||||
|
SafeFileHandle hDirectory,
|
||||||
|
void* lpBuffer,
|
||||||
|
uint nBufferLength,
|
||||||
|
bool bWatchSubtree,
|
||||||
|
uint dwNotifyFilter,
|
||||||
|
out uint lpBytesReturned,
|
||||||
|
IntPtr lpOverlapped,
|
||||||
|
IntPtr lpCompletionRoutine);
|
||||||
|
|
||||||
|
// ================================================================ 托管包装
|
||||||
|
|
||||||
|
/// <summary>把读/写缓冲固定后调用 DeviceIoControl;返回 false 时用 <see cref="Marshal.GetLastWin32Error"/> 取错误码。</summary>
|
||||||
|
internal static unsafe bool Ioctl(SafeFileHandle handle, uint code, ReadOnlySpan<byte> input, Span<byte> output, out int bytesReturned)
|
||||||
|
{
|
||||||
|
fixed (byte* pIn = input)
|
||||||
|
fixed (byte* pOut = output)
|
||||||
|
{
|
||||||
|
var ok = DeviceIoControl(
|
||||||
|
handle, code,
|
||||||
|
input.Length == 0 ? null : pIn, (uint)input.Length,
|
||||||
|
output.Length == 0 ? null : pOut, (uint)output.Length,
|
||||||
|
out var ret, IntPtr.Zero);
|
||||||
|
bytesReturned = (int)ret;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>在卷句柄上做一次带偏移的同步读取(卷句柄偏移 = 卷内绝对字节偏移)。</summary>
|
||||||
|
internal static unsafe int ReadAt(SafeFileHandle handle, Span<byte> buffer, long offset)
|
||||||
|
{
|
||||||
|
if (!SetFilePointerEx(handle, offset, out _, FILE_BEGIN)) return -1;
|
||||||
|
fixed (byte* p = buffer)
|
||||||
|
{
|
||||||
|
if (!ReadFile(handle, p, (uint)buffer.Length, out var read, IntPtr.Zero)) return -1;
|
||||||
|
return (int)read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按路径取文件的 64 位 FRN 与基本元数据(大小/属性/最后写入时间)。
|
||||||
|
/// 这是 RDCW 回退监听能定位索引条目的基础:句柄上的 FileIndex 与 USN 的 FRN 同口径。
|
||||||
|
/// 只要求 FILE_READ_ATTRIBUTES,普通用户也能用(目录需要 FILE_FLAG_BACKUP_SEMANTICS)。
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryStatPath(string path, out ByHandleFileInformation info)
|
||||||
|
{
|
||||||
|
info = default;
|
||||||
|
var handle = CreateFileW(
|
||||||
|
path,
|
||||||
|
FILE_READ_ATTRIBUTES,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||||
|
IntPtr.Zero,
|
||||||
|
OPEN_EXISTING,
|
||||||
|
FILE_FLAG_BACKUP_SEMANTICS,
|
||||||
|
IntPtr.Zero);
|
||||||
|
if (handle.IsInvalid)
|
||||||
|
{
|
||||||
|
handle.Dispose();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return GetFileInformationByHandle(handle, out info);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
handle.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把 Win32 错误码翻译成中文可读信息,供 UI 直接展示。</summary>
|
||||||
|
internal static string DescribeError(int error, string? context = null)
|
||||||
|
{
|
||||||
|
var text = error switch
|
||||||
|
{
|
||||||
|
ERROR_ACCESS_DENIED => "访问被拒绝(需要管理员权限)",
|
||||||
|
ERROR_INVALID_FUNCTION => "函数不正确(非 NTFS 卷、或无管理员权限调用 NTFS 专属 FSCTL 都会返回它)",
|
||||||
|
ERROR_NOT_SUPPORTED => "该卷不支持此操作",
|
||||||
|
ERROR_JOURNAL_NOT_ACTIVE => "该卷未启用 USN 变更日志",
|
||||||
|
ERROR_JOURNAL_DELETE_IN_PROGRESS => "USN 变更日志正在被删除",
|
||||||
|
ERROR_JOURNAL_ENTRY_DELETED => "请求的 USN 记录已被删除",
|
||||||
|
ERROR_HANDLE_EOF => "已到数据末尾",
|
||||||
|
ERROR_OPERATION_ABORTED => "操作已取消",
|
||||||
|
ERROR_CANCELLED => "操作已取消",
|
||||||
|
ERROR_NOT_READY => "卷未就绪",
|
||||||
|
_ => SafeSystemMessage(error)
|
||||||
|
};
|
||||||
|
return context is null ? text : $"{context}:{text}(Win32 错误 {error})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeSystemMessage(int error)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new Win32Exception(error).Message;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "未知错误";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ 记录号归一化
|
||||||
|
|
||||||
|
/// <summary>MFT 记录号掩码:FRN 的低 48 位。</summary>
|
||||||
|
internal const ulong RecordNumberMask = 0x0000_FFFF_FFFF_FFFFUL;
|
||||||
|
|
||||||
|
/// <summary>剥掉 FRN 高 16 位的序列号,只保留 MFT 记录号(全索引统一用这个做键)。</summary>
|
||||||
|
internal static ulong NormalizeFrn(ulong frn) => frn & RecordNumberMask;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
|||||||
|
namespace FluidExplorer.Services.Search.Usn;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通配符匹配器:<c>?</c> 匹配任意单字符,<c>*</c> 匹配任意长度(含空),
|
||||||
|
/// 大小写不敏感(用固定区域的大小写折叠,不产生任何分配、不受当前区域影响)。
|
||||||
|
///
|
||||||
|
/// 实现为经典的“双指针 + 最近星号回溯”,最坏 O(n*m),但对文件名这种短串是纳秒级。
|
||||||
|
/// </summary>
|
||||||
|
public static class WildcardMatcher
|
||||||
|
{
|
||||||
|
public static bool IsMatch(ReadOnlySpan<char> text, ReadOnlySpan<char> pattern)
|
||||||
|
{
|
||||||
|
// ---- 快路径:绝大多数真实查询是 "*.json" / "log*" / "*tmp*" 这类“只有一个/两个通配符”的模式,
|
||||||
|
// 直接退化成 EndsWith/StartsWith/IndexOf(走的是 BCL 的高度优化实现),比通用回溯快一个数量级。
|
||||||
|
int stars = 0, questions = 0, firstStar = -1, lastStar = -1;
|
||||||
|
for (int i = 0; i < pattern.Length; i++)
|
||||||
|
{
|
||||||
|
var c = pattern[i];
|
||||||
|
if (c == '*')
|
||||||
|
{
|
||||||
|
stars++;
|
||||||
|
if (firstStar < 0) firstStar = i;
|
||||||
|
lastStar = i;
|
||||||
|
}
|
||||||
|
else if (c == '?')
|
||||||
|
{
|
||||||
|
questions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questions == 0)
|
||||||
|
{
|
||||||
|
switch (stars)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
return text.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||||
|
case 1 when firstStar == 0:
|
||||||
|
return text.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
|
||||||
|
case 1 when firstStar == pattern.Length - 1:
|
||||||
|
return text.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase);
|
||||||
|
case 2 when firstStar == 0 && lastStar == pattern.Length - 1:
|
||||||
|
return text.IndexOf(pattern[1..^1], StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 通用路径:双指针 + 最近星号回溯 ----
|
||||||
|
int t = 0, p = 0;
|
||||||
|
int starPattern = -1;
|
||||||
|
int starText = 0;
|
||||||
|
|
||||||
|
while (t < text.Length)
|
||||||
|
{
|
||||||
|
if (p < pattern.Length && (pattern[p] == '?' || FoldEquals(pattern[p], text[t])))
|
||||||
|
{
|
||||||
|
t++;
|
||||||
|
p++;
|
||||||
|
}
|
||||||
|
else if (p < pattern.Length && pattern[p] == '*')
|
||||||
|
{
|
||||||
|
// 记下最近的星号位置,先当它匹配空串继续往前走
|
||||||
|
starPattern = p++;
|
||||||
|
starText = t;
|
||||||
|
}
|
||||||
|
else if (starPattern >= 0)
|
||||||
|
{
|
||||||
|
// 回溯:让最近的星号多吃一个字符
|
||||||
|
p = starPattern + 1;
|
||||||
|
t = ++starText;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (p < pattern.Length && pattern[p] == '*') p++;
|
||||||
|
return p == pattern.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>是否包含通配符。</summary>
|
||||||
|
public static bool HasWildcard(ReadOnlySpan<char> pattern) =>
|
||||||
|
pattern.IndexOfAny('*', '?') >= 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 返回模式开头连续的字面量前缀(遇到 * 或 ? 为止),用于给通配符命中排优先级。
|
||||||
|
/// </summary>
|
||||||
|
public static ReadOnlySpan<char> LiteralPrefix(ReadOnlySpan<char> pattern)
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
while (i < pattern.Length && pattern[i] is not ('*' or '?')) i++;
|
||||||
|
return pattern[..i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>区域无关的大小写折叠比较(只处理 ASCII 与 BMP 常见情形,足够文件名使用)。</summary>
|
||||||
|
internal static bool FoldEquals(char a, char b) =>
|
||||||
|
a == b || char.ToUpperInvariant(a) == char.ToUpperInvariant(b);
|
||||||
|
|
||||||
|
/// <summary>模式里是否只剩星号(即 "*" 或 "**" 这类恒真模式)。</summary>
|
||||||
|
internal static bool IsMatchAll(ReadOnlySpan<char> pattern)
|
||||||
|
{
|
||||||
|
foreach (var c in pattern)
|
||||||
|
{
|
||||||
|
if (c != '*') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Shell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用外壳自己的 API(SHGetKnownFolderPath)解析系统文件夹,
|
||||||
|
/// 保证和资源管理器指向同一批真实位置(含 OneDrive 重定向后的"桌面/文档"等)。
|
||||||
|
/// </summary>
|
||||||
|
public static class KnownFolders
|
||||||
|
{
|
||||||
|
public static string Profile { get; } = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
public static string Desktop { get; } = Get(FolderId.Desktop);
|
||||||
|
public static string Documents { get; } = Get(FolderId.Documents);
|
||||||
|
public static string Downloads { get; } = Get(FolderId.Downloads);
|
||||||
|
public static string Pictures { get; } = Get(FolderId.Pictures);
|
||||||
|
public static string Music { get; } = Get(FolderId.Music);
|
||||||
|
public static string Videos { get; } = Get(FolderId.Videos);
|
||||||
|
public static string RecycleBin { get; } = @"shell:RecycleBinFolder";
|
||||||
|
|
||||||
|
/// <summary>此电脑 / 回收站等虚拟外壳对象的解析名,交给外壳取图标与打开。</summary>
|
||||||
|
public const string ThisPcParsingName = "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}";
|
||||||
|
public const string RecycleBinParsingName = "::{645FF040-5081-101B-9F08-00AA002F954E}";
|
||||||
|
public const string NetworkParsingName = "::{F02C1A0D-BE21-4350-88B0-7367FC96EF3C}";
|
||||||
|
public const string HomeParsingName = "::{F874310E-B6B7-47DC-BC84-B9E6B38F5903}"; // 主页
|
||||||
|
public const string GalleryParsingName = "::{E88865EA-0E1C-4E20-9AA6-EDCD0212C87C}"; // 图库
|
||||||
|
|
||||||
|
private static string Get(Guid id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hr = SHGetKnownFolderPath(ref id, 0, IntPtr.Zero, out var ptr);
|
||||||
|
if (hr != 0 || ptr == IntPtr.Zero) return string.Empty;
|
||||||
|
try { return Marshal.PtrToStringUni(ptr) ?? string.Empty; }
|
||||||
|
finally { Marshal.FreeCoTaskMem(ptr); }
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
|
||||||
|
private static extern int SHGetKnownFolderPath(ref Guid rfid, uint dwFlags, IntPtr hToken, out IntPtr ppszPath);
|
||||||
|
|
||||||
|
private static class FolderId
|
||||||
|
{
|
||||||
|
public static Guid Desktop = new("B4BFCC3A-DB2C-424C-B029-7FE99A87C641");
|
||||||
|
public static Guid Documents = new("FDD39AD0-238F-46AF-ADB4-6C85480369C7");
|
||||||
|
public static Guid Downloads = new("374DE290-123F-4565-9164-39C4925E467B");
|
||||||
|
public static Guid Pictures = new("33E28130-4E1E-4676-835A-98395C3BC3BB");
|
||||||
|
public static Guid Music = new("4BD8D571-6D19-48D3-BE97-422220080E43");
|
||||||
|
public static Guid Videos = new("18989B1D-99B5-455B-841C-AB7C74E4DDFC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 卷信息(用于侧边栏"此电脑"和状态栏):显示名、总容量、可用空间、就绪状态。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DriveItem
|
||||||
|
{
|
||||||
|
public required string RootPath { get; init; }
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
public required string VolumeLabel { get; init; }
|
||||||
|
public required string FileSystem { get; init; }
|
||||||
|
public long TotalBytes { get; init; }
|
||||||
|
public long FreeBytes { get; init; }
|
||||||
|
public int DriveType { get; init; }
|
||||||
|
public bool IsReady { get; init; }
|
||||||
|
|
||||||
|
public double UsedRatio => TotalBytes <= 0 ? 0 : 1.0 - (double)FreeBytes / TotalBytes;
|
||||||
|
|
||||||
|
public string CapacityText => !IsReady || TotalBytes <= 0
|
||||||
|
? "不可用"
|
||||||
|
: $"{FluidExplorer.Models.FileEntry.FormatSize(FreeBytes)} 可用,共 {FluidExplorer.Models.FileEntry.FormatSize(TotalBytes)}";
|
||||||
|
|
||||||
|
public string Glyph => DriveType switch
|
||||||
|
{
|
||||||
|
2 => "\uE88E", // 可移动磁盘
|
||||||
|
3 => "\uEDA2", // 本地磁盘
|
||||||
|
4 => "\uE8CE", // 网络驱动器
|
||||||
|
5 => "\uE958", // 光驱
|
||||||
|
_ => "\uEDA2"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static IReadOnlyList<DriveItem> Enumerate()
|
||||||
|
{
|
||||||
|
var list = new List<DriveItem>();
|
||||||
|
foreach (var drive in DriveInfo.GetDrives())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var label = drive.IsReady ? drive.VolumeLabel : string.Empty;
|
||||||
|
var display = string.IsNullOrWhiteSpace(label)
|
||||||
|
? (drive.Name.TrimEnd('\\') is { Length: > 0 } letter ? $"本地磁盘 ({letter})" : drive.Name)
|
||||||
|
: $"{label} ({drive.Name.TrimEnd('\\')})";
|
||||||
|
list.Add(new DriveItem
|
||||||
|
{
|
||||||
|
RootPath = drive.Name,
|
||||||
|
DisplayName = display,
|
||||||
|
VolumeLabel = label,
|
||||||
|
FileSystem = drive.IsReady ? drive.DriveFormat : string.Empty,
|
||||||
|
TotalBytes = drive.IsReady ? drive.TotalSize : 0,
|
||||||
|
FreeBytes = drive.IsReady ? drive.TotalFreeSpace : 0,
|
||||||
|
DriveType = (int)drive.DriveType,
|
||||||
|
IsReady = drive.IsReady
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 未就绪的驱动器(空读卡器等)直接跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Shell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 资源管理器"名称"列用的排序:调用系统 shlwapi 的 StrCmpLogicalW(自然排序,数字按数值比较)。
|
||||||
|
/// 这样 "文件2" 会排在 "文件10" 前面,和原版体验一致。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NaturalStringComparer : IComparer<string>
|
||||||
|
{
|
||||||
|
public static NaturalStringComparer Instance { get; } = new();
|
||||||
|
|
||||||
|
public int Compare(string? x, string? y)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(x, y)) return 0;
|
||||||
|
if (x is null) return -1;
|
||||||
|
if (y is null) return 1;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return StrCmpLogicalW(x, y);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return string.Compare(x, y, StringComparison.CurrentCultureIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("shlwapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
|
||||||
|
private static extern int StrCmpLogicalW(string psz1, string psz2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Shell;
|
||||||
|
|
||||||
|
/// <summary>回收站里的一条记录(从 $I 元数据文件解析,含原始路径)。</summary>
|
||||||
|
public sealed record RecycleBinEntry(
|
||||||
|
string RecyclePath,
|
||||||
|
string OriginalPath,
|
||||||
|
long Size,
|
||||||
|
DateTime DeletedUtc,
|
||||||
|
string VolumeRoot)
|
||||||
|
{
|
||||||
|
public string Name => Services.FileSystem.PathHelper.GetName(OriginalPath);
|
||||||
|
public string OriginalDirectory => Services.FileSystem.PathHelper.GetParent(OriginalPath);
|
||||||
|
public bool IsDirectory => Size == 0 && OriginalPath.Length > 0 && !Path.HasExtension(OriginalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 直接读取各卷的 $Recycle.Bin\<SID>\$I* 元数据文件,得到回收站内容与原始路径。
|
||||||
|
/// 这样"还原/彻底删除"都能由本程序完成(不依赖系统弹窗),也支持一步撤销。
|
||||||
|
/// </summary>
|
||||||
|
public static class RecycleBinView
|
||||||
|
{
|
||||||
|
public static async Task<IReadOnlyList<RecycleBinEntry>> EnumerateAsync(CancellationToken cancellationToken)
|
||||||
|
=> await Task.Run(() => Enumerate(cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
public static IReadOnlyList<RecycleBinEntry> Enumerate(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var results = new List<RecycleBinEntry>();
|
||||||
|
foreach (var drive in DriveInfo.GetDrives())
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!drive.IsReady || drive.DriveType != DriveType.Fixed) continue;
|
||||||
|
var binRoot = Path.Combine(drive.Name, "$Recycle.Bin");
|
||||||
|
if (!Directory.Exists(binRoot)) continue;
|
||||||
|
|
||||||
|
foreach (var sidDir in Directory.EnumerateDirectories(binRoot))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var metaFile in Directory.EnumerateFiles(sidDir, "$I*"))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var entry = TryParse(metaFile, drive.Name);
|
||||||
|
if (entry is not null) results.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 其他用户的回收站目录通常无权限,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 卷不可读时跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>解析单个 $I 元数据文件。</summary>
|
||||||
|
public static RecycleBinEntry? TryParse(string metaFilePath, string volumeRoot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(metaFilePath);
|
||||||
|
if (bytes.Length < 24) return null;
|
||||||
|
|
||||||
|
var version = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(0, 8));
|
||||||
|
var size = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(8, 8));
|
||||||
|
var fileTime = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(16, 8));
|
||||||
|
var deleted = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue;
|
||||||
|
|
||||||
|
string originalPath;
|
||||||
|
if (version >= 2 && bytes.Length >= 28)
|
||||||
|
{
|
||||||
|
var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(24, 4));
|
||||||
|
if (length <= 0 || 28 + length * 2 > bytes.Length) return null;
|
||||||
|
originalPath = Encoding.Unicode.GetString(bytes, 28, length * 2).TrimEnd('\0');
|
||||||
|
}
|
||||||
|
else if (version == 1)
|
||||||
|
{
|
||||||
|
// 旧格式:路径固定在 0x2C 偏移处的 260 个宽字符
|
||||||
|
if (bytes.Length < 0x2C + 520) return null;
|
||||||
|
originalPath = Encoding.Unicode.GetString(bytes, 0x2C, 520).TrimEnd('\0');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(originalPath)) return null;
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(metaFilePath);
|
||||||
|
var recyclePath = Path.Combine(Path.GetDirectoryName(metaFilePath)!, "$R" + fileName[2..]);
|
||||||
|
return new RecycleBinEntry(recyclePath, originalPath, Math.Max(0, size), deleted, volumeRoot);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Shell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 交给 Windows 外壳去做的动作(打开、属性、打开方式、在资源管理器中显示…)。
|
||||||
|
/// 全部走系统原版行为,保证和资源管理器完全一致。
|
||||||
|
/// </summary>
|
||||||
|
public static class ShellActions
|
||||||
|
{
|
||||||
|
/// <summary>用默认程序/默认动作打开(= 双击)。</summary>
|
||||||
|
public static bool Open(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>打开"打开方式"选择器。</summary>
|
||||||
|
public static bool OpenWith(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo("rundll32.exe", $"shell32.dll,OpenAs_RunDLL {path}") { UseShellExecute = true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>调用系统原版"属性"对话框(含只读/隐藏复选框、磁盘清理等)。</summary>
|
||||||
|
public static bool ShowProperties(IntPtr ownerHwnd, IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
if (paths.Count == 0) return false;
|
||||||
|
var info = new SHELLEXECUTEINFO
|
||||||
|
{
|
||||||
|
cbSize = Marshal.SizeOf<SHELLEXECUTEINFO>(),
|
||||||
|
fMask = SEE_MASK_INVOKEIDLIST | SEE_MASK_FLAG_NO_UI,
|
||||||
|
hwnd = ownerHwnd,
|
||||||
|
lpVerb = "properties",
|
||||||
|
lpFile = paths[0],
|
||||||
|
nShow = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
if (paths.Count == 1) return ShellExecuteEx(ref info);
|
||||||
|
|
||||||
|
// 多选时用外壳的多文件属性对话框
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var files = string.Join('\0', paths) + "\0\0";
|
||||||
|
var ptr = Marshal.StringToHGlobalUni(files);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
info.lpFile = null;
|
||||||
|
var psi = new SHFILEINFO();
|
||||||
|
var hwnd = SHMultiFileProperties(new DataObjectNative { pFiles = ptr }, 0);
|
||||||
|
_ = hwnd;
|
||||||
|
_ = psi;
|
||||||
|
// SHMultiFileProperties 需要 IDataObject 实现,较繁琐;退化为逐项打开第一个的属性
|
||||||
|
info.lpFile = paths[0];
|
||||||
|
return ShellExecuteEx(ref info);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>在系统资源管理器中定位并选中该文件(用于"在资源管理器中显示")。</summary>
|
||||||
|
public static bool RevealInExplorer(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>运行对话框式的"运行"入口(用于 shell: 位置)。</summary>
|
||||||
|
public static bool OpenShellLocation(string parsingName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo("explorer.exe", parsingName) { UseShellExecute = true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把"打开"当作动词执行(用于右键菜单的"打开")。</summary>
|
||||||
|
public static bool Execute(string path, string verb)
|
||||||
|
{
|
||||||
|
var info = new SHELLEXECUTEINFO
|
||||||
|
{
|
||||||
|
cbSize = Marshal.SizeOf<SHELLEXECUTEINFO>(),
|
||||||
|
fMask = SEE_MASK_INVOKEIDLIST | SEE_MASK_FLAG_NO_UI,
|
||||||
|
lpVerb = verb,
|
||||||
|
lpFile = path,
|
||||||
|
nShow = 1
|
||||||
|
};
|
||||||
|
return ShellExecuteEx(ref info);
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct SHELLEXECUTEINFO
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public uint fMask;
|
||||||
|
public IntPtr hwnd;
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] public string? lpVerb;
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] public string? lpFile;
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] public string? lpParameters;
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] public string? lpDirectory;
|
||||||
|
public int nShow;
|
||||||
|
public IntPtr hInstApp;
|
||||||
|
public IntPtr lpIDList;
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] public string? lpClass;
|
||||||
|
public IntPtr hkeyClass;
|
||||||
|
public uint dwHotKey;
|
||||||
|
public IntPtr hIcon;
|
||||||
|
public IntPtr hProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct SHFILEINFO
|
||||||
|
{
|
||||||
|
public IntPtr hIcon;
|
||||||
|
public int iIcon;
|
||||||
|
public uint dwAttributes;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct DataObjectNative
|
||||||
|
{
|
||||||
|
public IntPtr pFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const uint SEE_MASK_INVOKEIDLIST = 0x0000000C;
|
||||||
|
private const uint SEE_MASK_FLAG_NO_UI = 0x00000400;
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern bool ShellExecuteEx(ref SHELLEXECUTEINFO lpExecInfo);
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr SHMultiFileProperties(DataObjectNative pdtobj, uint dwFlags);
|
||||||
|
|
||||||
|
/// <summary>把文件放入剪贴板(CF_HDROP,与其他程序互通)。</summary>
|
||||||
|
public static bool SetClipboardFiles(IReadOnlyList<string> paths, bool cut)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var p in paths) sb.Append(p).Append('\0');
|
||||||
|
sb.Append('\0');
|
||||||
|
var bytes = Encoding.Unicode.GetBytes(sb.ToString());
|
||||||
|
var hGlobal = Marshal.AllocHGlobal(bytes.Length + 20);
|
||||||
|
if (hGlobal == IntPtr.Zero) return false;
|
||||||
|
|
||||||
|
// DROPFILES 结构 + 文件名列表
|
||||||
|
var dropFiles = new byte[20 + bytes.Length];
|
||||||
|
BitConverter.GetBytes(20).CopyTo(dropFiles, 0); // pFiles 偏移
|
||||||
|
BitConverter.GetBytes(0).CopyTo(dropFiles, 4); // pt.x
|
||||||
|
BitConverter.GetBytes(0).CopyTo(dropFiles, 8); // pt.y
|
||||||
|
BitConverter.GetBytes(0).CopyTo(dropFiles, 12); // fNC
|
||||||
|
BitConverter.GetBytes(1).CopyTo(dropFiles, 16); // fWide = TRUE
|
||||||
|
bytes.CopyTo(dropFiles, 20);
|
||||||
|
Marshal.Copy(dropFiles, 0, hGlobal, dropFiles.Length);
|
||||||
|
|
||||||
|
var format = RegisterClipboardFormat(cut ? "Preferred DropEffect" : "Preferred DropEffect");
|
||||||
|
var effect = new byte[4];
|
||||||
|
BitConverter.GetBytes(cut ? 2 : 5).CopyTo(effect, 0); // DROPEFFECT_MOVE=2 / COPY=5
|
||||||
|
var hEffect = Marshal.AllocHGlobal(4);
|
||||||
|
Marshal.Copy(effect, 0, hEffect, 4);
|
||||||
|
|
||||||
|
if (!OpenClipboard(IntPtr.Zero)) { Marshal.FreeHGlobal(hGlobal); Marshal.FreeHGlobal(hEffect); return false; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EmptyClipboard();
|
||||||
|
SetClipboardData(15 /*CF_HDROP*/, hGlobal);
|
||||||
|
SetClipboardData(format, hEffect);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseClipboard();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)] private static extern bool OpenClipboard(IntPtr hWndNewOwner);
|
||||||
|
[DllImport("user32.dll", SetLastError = true)] private static extern bool CloseClipboard();
|
||||||
|
[DllImport("user32.dll", SetLastError = true)] private static extern bool EmptyClipboard();
|
||||||
|
[DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
|
||||||
|
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern uint RegisterClipboardFormat(string lpszFormat);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.InteropServices.ComTypes;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Shell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows 系统原版右键菜单(shell 的 IContextMenu):
|
||||||
|
/// 就是资源管理器"显示更多选项"里那一套(含第三方扩展、发送到、打开方式、Windows Terminal 等),
|
||||||
|
/// 直接用系统实现,不自己造菜单项。
|
||||||
|
/// </summary>
|
||||||
|
public static class ShellContextMenu
|
||||||
|
{
|
||||||
|
public static Task ShowAsync(IntPtr ownerHwnd, IReadOnlyList<string> paths, Windows.Graphics.PointInt32 screenPoint)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
|
||||||
|
|
||||||
|
void Run()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ShowCore(ownerHwnd, paths, screenPoint.X, screenPoint.Y);
|
||||||
|
tcs.TrySetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatcher is null || dispatcher.HasThreadAccess) Run();
|
||||||
|
else dispatcher.TryEnqueue(Run);
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowCore(IntPtr hwnd, IReadOnlyList<string> paths, int x, int y)
|
||||||
|
{
|
||||||
|
if (paths.Count == 0) return;
|
||||||
|
|
||||||
|
var pidls = new List<IntPtr>();
|
||||||
|
var allocated = new List<IntPtr>();
|
||||||
|
IShellFolder? parentFolder = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 取第一条的父文件夹,作为构建 IContextMenu 的宿主(多选时要求同一父目录)
|
||||||
|
var firstHr = SHParseDisplayName(paths[0], IntPtr.Zero, out var firstPidl, 0, out _);
|
||||||
|
if (firstHr != 0 || firstPidl == IntPtr.Zero) return;
|
||||||
|
allocated.Add(firstPidl);
|
||||||
|
|
||||||
|
var bindHr = SHBindToParent(firstPidl, typeof(IShellFolder).GUID, out var folderObj, out var childPidl);
|
||||||
|
if (bindHr != 0 || folderObj is null) return;
|
||||||
|
parentFolder = (IShellFolder)folderObj;
|
||||||
|
|
||||||
|
var childPidls = new List<IntPtr> { childPidl };
|
||||||
|
for (var i = 1; i < paths.Count; i++)
|
||||||
|
{
|
||||||
|
if (SHParseDisplayName(paths[i], IntPtr.Zero, out var pidl, 0, out _) != 0 || pidl == IntPtr.Zero) continue;
|
||||||
|
allocated.Add(pidl);
|
||||||
|
var hr = SHBindToParent(pidl, typeof(IShellFolder).GUID, out var folder, out var child);
|
||||||
|
if (hr != 0 || folder is null) continue;
|
||||||
|
if (folder != parentFolder) continue; // 不同目录的多选:忽略额外项(由上层逐个处理)
|
||||||
|
childPidls.Add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
var iid = typeof(IContextMenu).GUID;
|
||||||
|
var uiObjectHr = parentFolder.GetUIObjectOf(hwnd, (uint)childPidls.Count, childPidls.ToArray(), ref iid, IntPtr.Zero, out var contextMenuObj);
|
||||||
|
if (uiObjectHr != 0 || contextMenuObj is null) return;
|
||||||
|
|
||||||
|
var contextMenu = (IContextMenu)contextMenuObj;
|
||||||
|
var contextMenu2 = contextMenuObj as IContextMenu2;
|
||||||
|
var contextMenu3 = contextMenuObj as IContextMenu3;
|
||||||
|
|
||||||
|
var hMenu = CreatePopupMenu();
|
||||||
|
if (hMenu == IntPtr.Zero) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const uint CMF_NORMAL = 0x00000000;
|
||||||
|
const uint CMF_EXTENDEDVERBS = 0x00000100;
|
||||||
|
var queryHr = contextMenu.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_NORMAL | CMF_EXTENDEDVERBS);
|
||||||
|
if (queryHr < 0) return;
|
||||||
|
|
||||||
|
// 系统菜单里"打开方式/发送到"等子菜单需要转发 owner-draw 消息
|
||||||
|
using var hook = contextMenu2 is null && contextMenu3 is null
|
||||||
|
? null
|
||||||
|
: new MenuMessageHook(hwnd, contextMenu2, contextMenu3);
|
||||||
|
|
||||||
|
const uint TPM_RETURNCMD = 0x0100;
|
||||||
|
const uint TPM_RIGHTBUTTON = 0x0002;
|
||||||
|
var command = TrackPopupMenuEx(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON, x, y, hwnd, IntPtr.Zero);
|
||||||
|
if (command <= 0) return;
|
||||||
|
|
||||||
|
var info = new CMINVOKECOMMANDINFOEX
|
||||||
|
{
|
||||||
|
cbSize = Marshal.SizeOf<CMINVOKECOMMANDINFOEX>(),
|
||||||
|
fMask = 0x00004000 /*CMIC_MASK_UNICODE*/,
|
||||||
|
hwnd = hwnd,
|
||||||
|
lpVerb = (IntPtr)(command - 1),
|
||||||
|
lpVerbW = (IntPtr)(command - 1),
|
||||||
|
nShow = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var invokeHr = contextMenu.InvokeCommand(ref info);
|
||||||
|
_ = invokeHr;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DestroyMenu(hMenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var pidl in allocated) Marshal.FreeCoTaskMem(pidl);
|
||||||
|
if (parentFolder is not null) Marshal.ReleaseComObject(parentFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把菜单的 owner-draw / init 消息转发给 IContextMenu2/3(子菜单才能正常展开)。</summary>
|
||||||
|
private sealed class MenuMessageHook : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IntPtr _hwnd;
|
||||||
|
private readonly IContextMenu2? _menu2;
|
||||||
|
private readonly IContextMenu3? _menu3;
|
||||||
|
private readonly SubclassProc _proc;
|
||||||
|
private readonly IntPtr _oldProc;
|
||||||
|
|
||||||
|
public MenuMessageHook(IntPtr hwnd, IContextMenu2? menu2, IContextMenu3? menu3)
|
||||||
|
{
|
||||||
|
_hwnd = hwnd;
|
||||||
|
_menu2 = menu2;
|
||||||
|
_menu3 = menu3;
|
||||||
|
_proc = HookProc;
|
||||||
|
_oldProc = SetWindowLongPtr(hwnd, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_proc));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntPtr HookProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam)
|
||||||
|
{
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
case WM_INITMENUPOPUP:
|
||||||
|
case WM_DRAWITEM:
|
||||||
|
case WM_MEASUREITEM:
|
||||||
|
case WM_MENUCHAR:
|
||||||
|
if (_menu3 is not null)
|
||||||
|
{
|
||||||
|
var handled = IntPtr.Zero;
|
||||||
|
if (_menu3.HandleMenuMsg2(msg, wParam, lParam, out handled) == 0 && handled != IntPtr.Zero) return handled;
|
||||||
|
}
|
||||||
|
else if (_menu2 is not null)
|
||||||
|
{
|
||||||
|
if (_menu2.HandleMenuMsg(msg, wParam, lParam) == 0) return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return CallWindowProc(_oldProc, hwnd, msg, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_oldProc != IntPtr.Zero) SetWindowLongPtr(_hwnd, GWLP_WNDPROC, _oldProc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int GWLP_WNDPROC = -4;
|
||||||
|
private const uint WM_INITMENUPOPUP = 0x0117;
|
||||||
|
private const uint WM_DRAWITEM = 0x002B;
|
||||||
|
private const uint WM_MEASUREITEM = 0x002C;
|
||||||
|
private const uint WM_MENUCHAR = 0x0120;
|
||||||
|
|
||||||
|
private delegate IntPtr SubclassProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct CMINVOKECOMMANDINFOEX
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public uint fMask;
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public IntPtr lpVerb;
|
||||||
|
public IntPtr lpParameters;
|
||||||
|
public IntPtr lpDirectory;
|
||||||
|
public int nShow;
|
||||||
|
public uint dwHotKey;
|
||||||
|
public IntPtr hIcon;
|
||||||
|
public IntPtr lpTitle;
|
||||||
|
public IntPtr lpVerbW;
|
||||||
|
public IntPtr lpParametersW;
|
||||||
|
public IntPtr lpDirectoryW;
|
||||||
|
public IntPtr lpTitleW;
|
||||||
|
public POINT ptInvoke;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct POINT
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport]
|
||||||
|
[Guid("000214E6-0000-0000-C000-000000000046")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IShellFolder
|
||||||
|
{
|
||||||
|
[PreserveSig] int ParseDisplayName(IntPtr hwnd, IntPtr pbc, [MarshalAs(UnmanagedType.LPWStr)] string pszDisplayName, out uint pchEaten, out IntPtr ppidl, ref uint pdwAttributes);
|
||||||
|
[PreserveSig] int EnumObjects(IntPtr hwnd, uint grfFlags, out IntPtr ppenumIDList);
|
||||||
|
[PreserveSig] int BindToObject(IntPtr pidl, IntPtr pbc, ref Guid riid, out IntPtr ppv);
|
||||||
|
[PreserveSig] int BindToStorage(IntPtr pidl, IntPtr pbc, ref Guid riid, out IntPtr ppv);
|
||||||
|
[PreserveSig] int CompareIDs(IntPtr lParam, IntPtr pidl1, IntPtr pidl2);
|
||||||
|
[PreserveSig] int CreateViewObject(IntPtr hwndOwner, ref Guid riid, out IntPtr ppv);
|
||||||
|
[PreserveSig] int GetAttributesOf(uint cidl, IntPtr[] apidl, ref uint rgfInOut);
|
||||||
|
[PreserveSig] int GetUIObjectOf(IntPtr hwndOwner, uint cidl, IntPtr[] apidl, ref Guid riid, IntPtr rgfReserved, out object ppv);
|
||||||
|
[PreserveSig] int GetDisplayNameOf(IntPtr pidl, uint uFlags, out IntPtr pName);
|
||||||
|
[PreserveSig] int SetNameOf(IntPtr hwnd, IntPtr pidl, [MarshalAs(UnmanagedType.LPWStr)] string pszName, uint uFlags, out IntPtr ppidlOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport]
|
||||||
|
[Guid("000214E4-0000-0000-C000-000000000046")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IContextMenu
|
||||||
|
{
|
||||||
|
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
|
||||||
|
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
|
||||||
|
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport]
|
||||||
|
[Guid("000214F4-0000-0000-C000-000000000046")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IContextMenu2
|
||||||
|
{
|
||||||
|
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
|
||||||
|
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
|
||||||
|
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
|
||||||
|
[PreserveSig] int HandleMenuMsg(uint uMsg, IntPtr wParam, IntPtr lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ComImport]
|
||||||
|
[Guid("BCFCE0A0-EC17-11D0-8D10-00A0C90F2719")]
|
||||||
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
|
private interface IContextMenu3
|
||||||
|
{
|
||||||
|
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
|
||||||
|
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
|
||||||
|
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
|
||||||
|
[PreserveSig] int HandleMenuMsg(uint uMsg, IntPtr wParam, IntPtr lParam);
|
||||||
|
[PreserveSig] int HandleMenuMsg2(uint uMsg, IntPtr wParam, IntPtr lParam, out IntPtr plResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int SHParseDisplayName(string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut);
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int SHBindToParent(IntPtr pidl, Guid riid, out object ppv, out IntPtr ppidlLast);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")] private static extern IntPtr CreatePopupMenu();
|
||||||
|
[DllImport("user32.dll")] private static extern bool DestroyMenu(IntPtr hMenu);
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int TrackPopupMenuEx(IntPtr hMenu, uint fuFlags, int x, int y, IntPtr hwnd, IntPtr lptpm);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||||
|
private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Services.Shell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用注册表把扩展名映射成资源管理器里显示的友好类型名(例如 "PDF 文档"、"文本文档")。
|
||||||
|
/// 结果缓存;失败时回退到 "XXX 文件"。
|
||||||
|
/// </summary>
|
||||||
|
public static class TypeNameResolver
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<string, string> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static string GetTypeName(string extension, bool isDirectory)
|
||||||
|
{
|
||||||
|
if (isDirectory) return "文件夹";
|
||||||
|
if (string.IsNullOrEmpty(extension)) return "文件";
|
||||||
|
return Cache.GetOrAdd(extension, static ext =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var key = "." + ext;
|
||||||
|
using var extKey = Registry.ClassesRoot.OpenSubKey(key);
|
||||||
|
if (extKey is null) return $"{ext.ToUpperInvariant()} 文件";
|
||||||
|
|
||||||
|
var progId = extKey.GetValue(null) as string;
|
||||||
|
if (!string.IsNullOrEmpty(progId) && (progId.StartsWith("AppX", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| progId.Contains("_", StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
// AppX/UWP 关联:优先用 "FriendlyTypeName"(本地化资源,直接读字符串)
|
||||||
|
var friendly = extKey.GetValue("FriendlyTypeName") as string;
|
||||||
|
if (!string.IsNullOrWhiteSpace(friendly)) return friendly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(progId))
|
||||||
|
{
|
||||||
|
using var progKey = Registry.ClassesRoot.OpenSubKey(progId);
|
||||||
|
if (progKey is not null)
|
||||||
|
{
|
||||||
|
var name = progKey.GetValue("FriendlyTypeName") as string ?? progKey.GetValue(null) as string;
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
// 间接字符串(@dll,-id)无法直接解析,交回扩展名兜底
|
||||||
|
return name.StartsWith('@') ? $"{ext.ToUpperInvariant()} 文件" : name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var extFriendly = extKey.GetValue("FriendlyTypeName") as string;
|
||||||
|
if (!string.IsNullOrWhiteSpace(extFriendly) && !extFriendly.StartsWith('@')) return extFriendly;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 注册表不可读(权限/损坏)时静默回退
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{ext.ToUpperInvariant()} 文件";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
图标全部取自 Windows 11 系统自带图标字体 Segoe Fluent Icons(SymbolThemeFontFamily),
|
||||||
|
即系统资源管理器使用的同一套字形,不引入任何第三方图标库。
|
||||||
|
每个码点都用 tools/render_glyphs.ps1 渲染核对过。
|
||||||
|
-->
|
||||||
|
<x:String x:Key="GlyphNew"></x:String>
|
||||||
|
<x:String x:Key="GlyphCut"></x:String>
|
||||||
|
<x:String x:Key="GlyphCopy"></x:String>
|
||||||
|
<x:String x:Key="GlyphPaste"></x:String>
|
||||||
|
<x:String x:Key="GlyphRename"></x:String>
|
||||||
|
<x:String x:Key="GlyphDelete"></x:String>
|
||||||
|
<x:String x:Key="GlyphUndo"></x:String>
|
||||||
|
<x:String x:Key="GlyphRedo"></x:String>
|
||||||
|
<x:String x:Key="GlyphSort"></x:String>
|
||||||
|
<x:String x:Key="GlyphView"></x:String>
|
||||||
|
<x:String x:Key="GlyphRefresh"></x:String>
|
||||||
|
<x:String x:Key="GlyphBack"></x:String>
|
||||||
|
<x:String x:Key="GlyphForward"></x:String>
|
||||||
|
<x:String x:Key="GlyphUp"></x:String>
|
||||||
|
<x:String x:Key="GlyphSearch"></x:String>
|
||||||
|
<x:String x:Key="GlyphSettings"></x:String>
|
||||||
|
<x:String x:Key="GlyphHome"></x:String>
|
||||||
|
<x:String x:Key="GlyphGallery"></x:String>
|
||||||
|
<x:String x:Key="GlyphFolder"></x:String>
|
||||||
|
<x:String x:Key="GlyphDrive"></x:String>
|
||||||
|
<x:String x:Key="GlyphNetwork"></x:String>
|
||||||
|
<x:String x:Key="GlyphRecycleBin"></x:String>
|
||||||
|
<x:String x:Key="GlyphThisPc"></x:String>
|
||||||
|
<x:String x:Key="GlyphMore"></x:String>
|
||||||
|
<x:String x:Key="GlyphInfo"></x:String>
|
||||||
|
<x:String x:Key="GlyphList"></x:String>
|
||||||
|
<x:String x:Key="GlyphDetails"></x:String>
|
||||||
|
<x:String x:Key="GlyphGrid"></x:String>
|
||||||
|
<x:String x:Key="GlyphDualPane"></x:String>
|
||||||
|
<x:String x:Key="GlyphShare"></x:String>
|
||||||
|
<x:String x:Key="GlyphProperties"></x:String>
|
||||||
|
<x:String x:Key="GlyphOpenInNew"></x:String>
|
||||||
|
<x:String x:Key="GlyphPin"></x:String>
|
||||||
|
<x:String x:Key="GlyphRestore"></x:String>
|
||||||
|
<x:String x:Key="GlyphPath"></x:String>
|
||||||
|
<x:String x:Key="GlyphStop"></x:String>
|
||||||
|
<x:String x:Key="GlyphPause"></x:String>
|
||||||
|
<x:String x:Key="GlyphPlay"></x:String>
|
||||||
|
<x:String x:Key="GlyphClear"></x:String>
|
||||||
|
<x:String x:Key="GlyphHidden"></x:String>
|
||||||
|
<x:String x:Key="GlyphExtension"></x:String>
|
||||||
|
<x:String x:Key="GlyphQueue"></x:String>
|
||||||
|
<x:String x:Key="GlyphRecent"></x:String>
|
||||||
|
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:helpers="using:FluidExplorer.Helpers">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
设计原则(克制):
|
||||||
|
1. 颜色全部使用 WinUI 内置主题资源,深浅色/强调色自动跟随系统,不写死任何色值。
|
||||||
|
2. 布局尺寸对齐资源管理器:工具栏 48、地址栏 40、详情行 30、状态栏 26。
|
||||||
|
3. 动画只保留 WinUI 控件自带的那一套(悬停/选中/展开/对话框),不额外叠加。
|
||||||
|
-->
|
||||||
|
|
||||||
|
<helpers:BoolToVisibilityConverter x:Key="BoolToVisibility" />
|
||||||
|
<helpers:StringToVisibilityConverter x:Key="StringToVisibility" />
|
||||||
|
<helpers:CountToVisibilityConverter x:Key="CountToVisibility" />
|
||||||
|
<helpers:InverseBoolConverter x:Key="InverseBool" />
|
||||||
|
<helpers:KindToVisibilityConverter x:Key="KindToVisibility" />
|
||||||
|
<helpers:JobStateTextConverter x:Key="JobStateText" />
|
||||||
|
<helpers:JobProgressTextConverter x:Key="JobProgressText" />
|
||||||
|
<helpers:JobPauseGlyphConverter x:Key="JobPauseGlyph" />
|
||||||
|
|
||||||
|
<x:Double x:Key="ToolbarHeight">48</x:Double>
|
||||||
|
<x:Double x:Key="AddressBarHeight">40</x:Double>
|
||||||
|
<x:Double x:Key="DetailRowHeight">30</x:Double>
|
||||||
|
<x:Double x:Key="StatusBarHeight">26</x:Double>
|
||||||
|
|
||||||
|
<!-- 工具栏按钮:资源管理器同款 32×32、4px 圆角、悬停用系统浅色填充 -->
|
||||||
|
<Style x:Key="ToolbarButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
|
||||||
|
<Setter Property="Width" Value="32" />
|
||||||
|
<Setter Property="Height" Value="32" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="ToolbarToggleStyle" TargetType="ToggleButton" BasedOn="{StaticResource DefaultToggleButtonStyle}">
|
||||||
|
<Setter Property="Width" Value="32" />
|
||||||
|
<Setter Property="Height" Value="32" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- 图标字体:Segoe Fluent Icons = Windows 11 系统自带图标字体 -->
|
||||||
|
<Style x:Key="GlyphIconStyle" TargetType="FontIcon">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource SymbolThemeFontFamily}" />
|
||||||
|
<Setter Property="FontSize" Value="16" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="StatusBarTextStyle" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PaneHintTextStyle" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{ThemeResource TextFillColorTertiaryBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- 详情视图列头 -->
|
||||||
|
<Style x:Key="ColumnHeaderButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="8,0" />
|
||||||
|
<Setter Property="Height" Value="28" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</ResourceDictionary>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>一个标签页:包含主窗格,可选第二窗格(双窗格模式,搬文件不用来回切)。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using FluidExplorer.Models;
|
||||||
|
using FluidExplorer.Services.Operations;
|
||||||
|
|
||||||
|
namespace FluidExplorer.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 操作队列面板里的一行。作业对象是在后台线程上更新并抛 PropertyChanged 的,
|
||||||
|
/// 直接绑定会跨线程更新 UI 而崩溃,因此这里在 UI 线程维护一份快照,由定时器按 250ms 刷新。
|
||||||
|
/// </summary>
|
||||||
|
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<string> { $"{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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>主视图模型:标签页、侧边栏、索引状态、操作队列、设置。</summary>
|
||||||
|
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<ExplorerTabViewModel> 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<SidebarNode> 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<SidebarNode> EnumerateNodes(IEnumerable<SidebarNode> 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<JobRowViewModel> 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<bool>? AnimationsToggled;
|
||||||
|
public event EventHandler<AppThemeMode>? 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<ExplorerPaneViewModel> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>启动:恢复上次的标签页并建立索引。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>侧边栏的一个节点(可展开的"此电脑"、可收藏的文件夹、驱动器等)。</summary>
|
||||||
|
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<SidebarNode> Children { get; } = [];
|
||||||
|
|
||||||
|
public bool HasUnrealizedChildren { get; set; }
|
||||||
|
|
||||||
|
/// <summary>当前文件夹是否属于该节点(用于侧边栏高亮,对齐资源管理器)。</summary>
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>面包屑的一段。</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>搜索结果列表里的一行。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<UserControl
|
||||||
|
x:Class="FluidExplorer.Views.ExplorerPaneView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:models="using:FluidExplorer.Models"
|
||||||
|
xmlns:vm="using:FluidExplorer.ViewModels"
|
||||||
|
xmlns:ops="using:FluidExplorer.Services.Operations"
|
||||||
|
Background="Transparent">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<!-- 行高对齐资源管理器(详情 30 / 列表 24),保留 WinUI 原生 ListViewItem 模板与动画 -->
|
||||||
|
<x:Double x:Key="ListViewItemMinHeight">30</x:Double>
|
||||||
|
<x:Double x:Key="GridViewItemMinHeight">0</x:Double>
|
||||||
|
<x:Double x:Key="GridViewItemMinWidth">0</x:Double>
|
||||||
|
|
||||||
|
<!-- 资源管理器的"图标+文字"工具按钮(新建 / 排序 / 查看):12px 文字、32 高、4 圆角 -->
|
||||||
|
<Style x:Key="ToolbarTextButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
|
||||||
|
<Setter Property="Height" Value="32" />
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="8,0" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
</Style>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Grid x:Name="PaneRoot">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- ═══ 命令栏(对齐资源管理器:新建 / 剪切复制粘贴 / 重命名 / 删除 / 撤销 / 排序 / 视图) ═══ -->
|
||||||
|
<Grid Grid.Row="0" Height="48" Padding="8,0" ColumnSpacing="4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="2">
|
||||||
|
<Button x:Name="SidebarToggle" Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="导航窗格" Click="OnToggleSidebarClick">
|
||||||
|
<FontIcon Glyph="" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource ToolbarTextButtonStyle}" ToolTipService.ToolTip="新建 (Ctrl+Shift+N)">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphNew}" FontSize="16" />
|
||||||
|
<TextBlock Text="新建" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button.Flyout>
|
||||||
|
<MenuFlyout Placement="BottomEdgeAlignedLeft">
|
||||||
|
<MenuFlyoutItem Text="文件夹" Click="OnNewFolderClick">
|
||||||
|
<MenuFlyoutItem.Icon><FontIcon Glyph="{StaticResource GlyphFolder}" /></MenuFlyoutItem.Icon>
|
||||||
|
</MenuFlyoutItem>
|
||||||
|
<MenuFlyoutItem Text="文本文档" Click="OnNewTextFileClick">
|
||||||
|
<MenuFlyoutItem.Icon><FontIcon Glyph="{StaticResource GlyphExtension}" /></MenuFlyoutItem.Icon>
|
||||||
|
</MenuFlyoutItem>
|
||||||
|
<MenuFlyoutSeparator />
|
||||||
|
<MenuFlyoutItem Text="新建标签页" Click="OnNewTabClick" />
|
||||||
|
</MenuFlyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2">
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="剪切 (Ctrl+X)" Click="OnCutClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphCut}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="复制 (Ctrl+C)" Click="OnCopyClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphCopy}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="粘贴 (Ctrl+V)" Click="OnPasteClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphPaste}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="重命名 (F2)" Click="OnRenameClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphRename}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="删除 (Delete)" Click="OnDeleteClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphDelete}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="2" Margin="8,0,0,0">
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="撤销 (Ctrl+Z)" Click="OnUndoClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphUndo}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
<Button x:Name="RestoreButton" Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="还原所选项目"
|
||||||
|
Click="OnRestoreClick" Visibility="Collapsed">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphRestore}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="2">
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="双窗格(Ctrl+Shift+D):左右对拖搬文件"
|
||||||
|
AutomationProperties.Name="双窗格" Click="OnToggleDualPaneClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphDualPane}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource ToolbarTextButtonStyle}" ToolTipService.ToolTip="排序方式">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphSort}" FontSize="16" />
|
||||||
|
<TextBlock Text="排序" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button.Flyout>
|
||||||
|
<MenuFlyout Placement="BottomEdgeAlignedRight">
|
||||||
|
<MenuFlyoutItem Text="名称" Tag="Name" Click="OnSortClick" />
|
||||||
|
<MenuFlyoutItem Text="修改日期" Tag="DateModified" Click="OnSortClick" />
|
||||||
|
<MenuFlyoutItem Text="类型" Tag="Type" Click="OnSortClick" />
|
||||||
|
<MenuFlyoutItem Text="大小" Tag="Size" Click="OnSortClick" />
|
||||||
|
<MenuFlyoutSeparator />
|
||||||
|
<MenuFlyoutItem Text="升序" Tag="Ascending" Click="OnSortDirectionClick" />
|
||||||
|
<MenuFlyoutItem Text="降序" Tag="Descending" Click="OnSortDirectionClick" />
|
||||||
|
</MenuFlyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Style="{StaticResource ToolbarTextButtonStyle}" ToolTipService.ToolTip="查看">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphView}" FontSize="16" />
|
||||||
|
<TextBlock Text="查看" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button.Flyout>
|
||||||
|
<MenuFlyout Placement="BottomEdgeAlignedRight">
|
||||||
|
<MenuFlyoutItem Text="超大图标" Tag="ExtraLargeIcons" Click="OnViewModeClick" />
|
||||||
|
<MenuFlyoutItem Text="大图标" Tag="LargeIcons" Click="OnViewModeClick" />
|
||||||
|
<MenuFlyoutItem Text="中图标" Tag="MediumIcons" Click="OnViewModeClick" />
|
||||||
|
<MenuFlyoutItem Text="小图标" Tag="SmallIcons" Click="OnViewModeClick" />
|
||||||
|
<MenuFlyoutItem Text="列表" Tag="List" Click="OnViewModeClick" />
|
||||||
|
<MenuFlyoutItem Text="详细信息" Tag="Details" Click="OnViewModeClick" />
|
||||||
|
<MenuFlyoutSeparator />
|
||||||
|
<ToggleMenuFlyoutItem x:Name="HiddenToggle" Text="显示隐藏的项目" Click="OnToggleHiddenClick" />
|
||||||
|
<ToggleMenuFlyoutItem x:Name="ExtensionToggle" Text="显示文件扩展名" Click="OnToggleExtensionClick" />
|
||||||
|
</MenuFlyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="更多选项" Click="OnShellContextMenuClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphMore}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="设置" Click="OnOpenSettingsClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphSettings}" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ═══ 地址栏(后退/前进/向上/刷新 + 面包屑 + 搜索框) ═══ -->
|
||||||
|
<Grid Grid.Row="1" Height="40" Padding="8,0" ColumnSpacing="4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Button Grid.Column="0" Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="后退 (Alt+←)" Click="OnBackClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphBack}" FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="1" Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="前进 (Alt+→)" Click="OnForwardClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphForward}" FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="向上 (Alt+↑)" Click="OnUpClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphUp}" FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3" Style="{StaticResource ToolbarButtonStyle}" ToolTipService.ToolTip="刷新 (F5)" Click="OnRefreshClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphRefresh}" FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Grid Grid.Column="4" Margin="4,4" CornerRadius="4" Background="{ThemeResource ControlFillColorDefaultBrush}">
|
||||||
|
<BreadcrumbBar x:Name="AddressBreadcrumb"
|
||||||
|
ItemsSource="{x:Bind Pane.Breadcrumbs, Mode=OneWay}"
|
||||||
|
ItemClicked="OnBreadcrumbClicked" />
|
||||||
|
<TextBox x:Name="AddressEditor"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
BorderThickness="0"
|
||||||
|
Background="Transparent"
|
||||||
|
KeyDown="OnAddressEditorKeyDown"
|
||||||
|
LostFocus="OnAddressEditorLostFocus" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<AutoSuggestBox x:Name="SearchBox"
|
||||||
|
Grid.Column="5"
|
||||||
|
Width="260"
|
||||||
|
MinWidth="160"
|
||||||
|
Height="32"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
QueryIcon="Find"
|
||||||
|
PlaceholderText="搜索"
|
||||||
|
TextChanged="OnSearchTextChanged"
|
||||||
|
QuerySubmitted="OnSearchQuerySubmitted"
|
||||||
|
SuggestionChosen="OnSearchSuggestionChosen" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 加载进度:不遮挡内容,只在标题下方显示一条细线 -->
|
||||||
|
<ProgressBar Grid.Row="2"
|
||||||
|
Height="2"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
Visibility="{x:Bind Pane.IsLoading, Mode=OneWay, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
|
||||||
|
<!-- ═══ 内容区(左:导航窗格 / 右:文件列表) ═══ -->
|
||||||
|
<Grid Grid.Row="3">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition x:Name="SidebarColumn" Width="242" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Grid x:Name="SidebarHost" Grid.Column="0" Padding="4,0,4,4">
|
||||||
|
<TreeView x:Name="SidebarTree"
|
||||||
|
ItemsSource="{x:Bind Main.SidebarRoots, Mode=OneWay}"
|
||||||
|
SelectionMode="Single"
|
||||||
|
ItemInvoked="OnSidebarItemInvoked"
|
||||||
|
Expanding="OnSidebarExpanding"
|
||||||
|
CanDragItems="False"
|
||||||
|
CanReorderItems="False">
|
||||||
|
<TreeView.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SidebarNode">
|
||||||
|
<TreeViewItem ItemsSource="{x:Bind Children}"
|
||||||
|
IsExpanded="{x:Bind IsExpanded, Mode=TwoWay}"
|
||||||
|
ToolTipService.ToolTip="{x:Bind Location.Path}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}"
|
||||||
|
Glyph="{x:Bind Glyph}"
|
||||||
|
FontSize="14" />
|
||||||
|
<TextBlock Text="{x:Bind DisplayName}" TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
</TreeViewItem>
|
||||||
|
</DataTemplate>
|
||||||
|
</TreeView.ItemTemplate>
|
||||||
|
</TreeView>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 文件列表所在层用系统"图层填充",工具栏/地址栏/导航窗格保持 Mica 透出(对齐资源管理器的层次感) -->
|
||||||
|
<Grid Grid.Column="1" Background="{ThemeResource LayerFillColorDefaultBrush}">
|
||||||
|
<!-- 文件夹内容 -->
|
||||||
|
<Grid Visibility="{x:Bind Pane.IsSearchActive, Mode=OneWay, Converter={StaticResource BoolToVisibility}, ConverterParameter=invert}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<InfoBar Grid.Row="0"
|
||||||
|
Margin="8,4"
|
||||||
|
Severity="Error"
|
||||||
|
IsOpen="{x:Bind Pane.HasError, Mode=OneWay}"
|
||||||
|
Message="{x:Bind Pane.ErrorMessage, Mode=OneWay}"
|
||||||
|
CloseButtonClick="OnDismissErrorClick" />
|
||||||
|
|
||||||
|
<!-- 详情视图列头 -->
|
||||||
|
<Grid Grid.Row="1"
|
||||||
|
Height="28"
|
||||||
|
Padding="12,0"
|
||||||
|
ColumnSpacing="8"
|
||||||
|
Visibility="{x:Bind Pane.IsDetailsView, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" MinWidth="160" />
|
||||||
|
<ColumnDefinition Width="140" />
|
||||||
|
<ColumnDefinition Width="140" />
|
||||||
|
<ColumnDefinition Width="96" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="0" Style="{StaticResource ColumnHeaderButtonStyle}" Content="名称" Tag="Name" Click="OnSortClick" />
|
||||||
|
<Button Grid.Column="1" Style="{StaticResource ColumnHeaderButtonStyle}" Content="修改日期" Tag="DateModified" Click="OnSortClick" />
|
||||||
|
<Button Grid.Column="2" Style="{StaticResource ColumnHeaderButtonStyle}" Content="类型" Tag="Type" Click="OnSortClick" />
|
||||||
|
<Button Grid.Column="3" Style="{StaticResource ColumnHeaderButtonStyle}" Content="大小" Tag="Size" Click="OnSortClick"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Right" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 详细信息 / 列表 -->
|
||||||
|
<ListView x:Name="DetailsList"
|
||||||
|
Grid.Row="2"
|
||||||
|
Padding="0,0,0,8"
|
||||||
|
SelectionMode="Extended"
|
||||||
|
ItemsSource="{x:Bind Pane.Items, Mode=OneWay}"
|
||||||
|
SelectionChanged="OnListSelectionChanged"
|
||||||
|
ContainerContentChanging="OnDetailsContainerChanging"
|
||||||
|
DoubleTapped="OnItemDoubleTapped"
|
||||||
|
KeyDown="OnListKeyDown"
|
||||||
|
ContextRequested="OnContextRequested"
|
||||||
|
AllowDrop="True"
|
||||||
|
CanDragItems="True"
|
||||||
|
DragItemsStarting="OnDragItemsStarting"
|
||||||
|
DragOver="OnDragOver"
|
||||||
|
Drop="OnDrop"
|
||||||
|
Visibility="{x:Bind Pane.IsDetailsView, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<ListView.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:ExplorerItem">
|
||||||
|
<Grid Height="30" Padding="0,0,8,0" ColumnSpacing="8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" MinWidth="160" />
|
||||||
|
<ColumnDefinition Width="140" />
|
||||||
|
<ColumnDefinition Width="140" />
|
||||||
|
<ColumnDefinition Width="96" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Grid Grid.Column="0" ColumnSpacing="8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<!-- 固定 28px 预留给复选框:勾选/取消勾选时文件名不会左右跳动(对齐资源管理器) -->
|
||||||
|
<ColumnDefinition Width="28" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<CheckBox Grid.Column="0"
|
||||||
|
MinWidth="0"
|
||||||
|
Margin="4,0,0,0"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsChecked="{x:Bind IsSelected, Mode=TwoWay}"
|
||||||
|
Visibility="{x:Bind ShowCheckBox, Mode=OneWay, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<Image Grid.Column="1"
|
||||||
|
Width="16"
|
||||||
|
Height="16"
|
||||||
|
Source="{x:Bind Icon, Mode=OneWay}" />
|
||||||
|
<Grid Grid.Column="2">
|
||||||
|
<TextBlock VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{x:Bind DisplayName, Mode=OneWay}"
|
||||||
|
Visibility="{x:Bind IsRenaming, Mode=OneWay, Converter={StaticResource BoolToVisibility}, ConverterParameter=invert}" />
|
||||||
|
<TextBox Tag="rename"
|
||||||
|
MinWidth="180"
|
||||||
|
Padding="4,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{x:Bind RenameText, Mode=TwoWay}"
|
||||||
|
Visibility="{x:Bind IsRenaming, Mode=OneWay, Converter={StaticResource BoolToVisibility}}"
|
||||||
|
KeyDown="OnRenameBoxKeyDown"
|
||||||
|
LostFocus="OnRenameBoxLostFocus" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
|
Text="{x:Bind ModifiedText, Mode=OneWay}" />
|
||||||
|
<TextBlock Grid.Column="2" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
|
Text="{x:Bind TypeText}" />
|
||||||
|
<TextBlock Grid.Column="3" VerticalAlignment="Center" HorizontalAlignment="Right"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
|
Text="{x:Bind SizeText}" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListView.ItemTemplate>
|
||||||
|
</ListView>
|
||||||
|
|
||||||
|
<!-- 图标 / 网格视图 -->
|
||||||
|
<GridView x:Name="IconsGrid"
|
||||||
|
Grid.Row="2"
|
||||||
|
Padding="12,8,12,12"
|
||||||
|
SelectionMode="Extended"
|
||||||
|
ItemsSource="{x:Bind Pane.Items, Mode=OneWay}"
|
||||||
|
SelectionChanged="OnListSelectionChanged"
|
||||||
|
ContainerContentChanging="OnIconsContainerChanging"
|
||||||
|
DoubleTapped="OnItemDoubleTapped"
|
||||||
|
KeyDown="OnListKeyDown"
|
||||||
|
ContextRequested="OnContextRequested"
|
||||||
|
AllowDrop="True"
|
||||||
|
CanDragItems="True"
|
||||||
|
DragItemsStarting="OnDragItemsStarting"
|
||||||
|
DragOver="OnDragOver"
|
||||||
|
Drop="OnDrop"
|
||||||
|
Visibility="{x:Bind Pane.IsIconsView, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<GridView.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="models:ExplorerItem">
|
||||||
|
<Grid Padding="4" CornerRadius="4">
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="6">
|
||||||
|
<Image Width="{x:Bind IconSize, Mode=OneWay}"
|
||||||
|
Height="{x:Bind IconSize, Mode=OneWay}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Source="{x:Bind Icon, Mode=OneWay}" />
|
||||||
|
<TextBlock Text="{x:Bind DisplayName, Mode=OneWay}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MaxLines="2"
|
||||||
|
TextAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Visibility="{x:Bind IsRenaming, Mode=OneWay, Converter={StaticResource BoolToVisibility}, ConverterParameter=invert}" />
|
||||||
|
<TextBox Tag="rename"
|
||||||
|
MinWidth="120"
|
||||||
|
TextAlignment="Center"
|
||||||
|
Text="{x:Bind RenameText, Mode=TwoWay}"
|
||||||
|
Visibility="{x:Bind IsRenaming, Mode=OneWay, Converter={StaticResource BoolToVisibility}}"
|
||||||
|
KeyDown="OnRenameBoxKeyDown"
|
||||||
|
LostFocus="OnRenameBoxLostFocus" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</GridView.ItemTemplate>
|
||||||
|
</GridView>
|
||||||
|
|
||||||
|
<!-- 空文件夹 / 提示 -->
|
||||||
|
<TextBlock Grid.Row="2"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="24"
|
||||||
|
TextAlignment="Center"
|
||||||
|
Style="{StaticResource PaneHintTextStyle}"
|
||||||
|
Text="{x:Bind Pane.EmptyMessage, Mode=OneWay}"
|
||||||
|
Visibility="{x:Bind Pane.EmptyMessage, Mode=OneWay, Converter={StaticResource StringToVisibility}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 搜索结果 -->
|
||||||
|
<Grid Visibility="{x:Bind Pane.IsSearchActive, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="0" Margin="12,8" Spacing="4">
|
||||||
|
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{x:Bind Pane.SearchStatusText, Mode=OneWay}" />
|
||||||
|
<ProgressBar IsIndeterminate="True"
|
||||||
|
Height="2"
|
||||||
|
Visibility="{x:Bind Pane.IsSearching, Mode=OneWay, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<InfoBar Grid.Row="1"
|
||||||
|
Margin="8,0,8,4"
|
||||||
|
Severity="Informational"
|
||||||
|
IsOpen="{x:Bind Pane.HasNote, Mode=OneWay}"
|
||||||
|
Message="{x:Bind Pane.NoteMessage, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<ListView Grid.Row="2"
|
||||||
|
Padding="0,0,0,8"
|
||||||
|
SelectionMode="Extended"
|
||||||
|
ItemsSource="{x:Bind Pane.SearchResults, Mode=OneWay}"
|
||||||
|
ContainerContentChanging="OnSearchContainerChanging"
|
||||||
|
DoubleTapped="OnSearchResultDoubleTapped"
|
||||||
|
ContextRequested="OnSearchResultContextRequested">
|
||||||
|
<ListView.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SearchResultItem">
|
||||||
|
<Grid Height="30" Padding="12,0" ColumnSpacing="8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" MinWidth="160" />
|
||||||
|
<ColumnDefinition Width="*" MinWidth="180" />
|
||||||
|
<ColumnDefinition Width="140" />
|
||||||
|
<ColumnDefinition Width="96" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid Grid.Column="0" ColumnSpacing="8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Image Grid.Column="0" Width="16" Height="16" Source="{x:Bind Icon, Mode=OneWay}" />
|
||||||
|
<TextBlock Grid.Column="1" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Text="{x:Bind Name}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Grid.Column="1" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind Directory}" />
|
||||||
|
<TextBlock Grid.Column="2" VerticalAlignment="Center"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind ModifiedText}" />
|
||||||
|
<TextBlock Grid.Column="3" VerticalAlignment="Center" HorizontalAlignment="Right"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind SizeText}" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListView.ItemTemplate>
|
||||||
|
</ListView>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ═══ 状态栏 ═══ -->
|
||||||
|
<Grid Grid.Row="4" Height="26" Padding="12,0" ColumnSpacing="12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Style="{StaticResource StatusBarTextStyle}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{x:Bind Pane.StatusText, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock x:Name="IndexChip"
|
||||||
|
Style="{StaticResource StatusBarTextStyle}"
|
||||||
|
Text="{x:Bind Main.IndexSummary, Mode=OneWay}"
|
||||||
|
ToolTipService.ToolTip="点击建立/重建文件索引(Everything 级搜索的前提)"
|
||||||
|
Tapped="OnIndexChipTapped" />
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" Width="28" Height="22"
|
||||||
|
ToolTipService.ToolTip="显示隐藏的项目" Click="OnToggleHiddenClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphHidden}" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" Width="28" Height="22"
|
||||||
|
ToolTipService.ToolTip="切换视图方式" Click="OnCycleViewClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphView}" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource ToolbarButtonStyle}"
|
||||||
|
Height="22"
|
||||||
|
Width="Auto"
|
||||||
|
Padding="8,0"
|
||||||
|
ToolTipService.ToolTip="文件操作队列"
|
||||||
|
Click="OnOpenQueueClick">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphQueue}" FontSize="12" />
|
||||||
|
<TextBlock Style="{StaticResource StatusBarTextStyle}" Text="{x:Bind Main.ActiveJobCount, Mode=OneWay}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单个浏览窗格的视图:命令栏 + 地址栏 + 内容区 + 状态栏。
|
||||||
|
/// 所有交互都委托给 <see cref="ExplorerPaneViewModel"/>,视图本身不碰文件系统。
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ExplorerPaneView : UserControl
|
||||||
|
{
|
||||||
|
private static IReadOnlyList<string> _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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>图标/磁贴尺寸跟随视图方式(网格用 ItemsWrapGrid 的单元格尺寸,避免布局跳动)。</summary>
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// 子项已在构建侧边栏时填充(驱动器数量少且读取廉价)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>当前路径变化时高亮侧边栏对应节点(对齐资源管理器)。</summary>
|
||||||
|
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<TreeViewNode> EnumerateNodes(IEnumerable<TreeViewNode> 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<SortColumn>(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<string> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ctrl+L / F4:切换到可编辑的地址输入(资源管理器行为)。</summary>
|
||||||
|
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<ExplorerItem> SelectedItems()
|
||||||
|
=> Pane is null
|
||||||
|
? []
|
||||||
|
: (Pane.IsDetailsView || Pane.IsIconsView
|
||||||
|
? (Pane.IsDetailsView ? DetailsList.SelectedItems : IconsGrid.SelectedItems).OfType<ExplorerItem>().ToList()
|
||||||
|
: []);
|
||||||
|
|
||||||
|
private void OnListSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Pane is null) return;
|
||||||
|
var listView = sender as ListViewBase;
|
||||||
|
var selected = listView?.SelectedItems.OfType<ExplorerItem>().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<SearchResultItem>().FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MenuFlyout BuildContextFlyout(IReadOnlyList<ExplorerItem> 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<ExplorerItem>().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<Windows.Storage.IStorageItem>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>拖到文件夹行上 → 放进该文件夹;拖到空白处 → 放进当前文件夹。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<UserControl
|
||||||
|
x:Class="FluidExplorer.Views.ExplorerTabView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:views="using:FluidExplorer.Views">
|
||||||
|
|
||||||
|
<!-- 单窗格 / 双窗格:双窗格是"搬文件"最快的形态(左右对拖,还可以跨标签页用剪贴板) -->
|
||||||
|
<Grid x:Name="LayoutRoot" Background="{ThemeResource LayerFillColorDefaultBrush}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition x:Name="FirstColumn" Width="*" />
|
||||||
|
<ColumnDefinition x:Name="SplitterColumn" Width="0" />
|
||||||
|
<ColumnDefinition x:Name="SecondColumn" Width="0" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<views:ExplorerPaneView x:Name="PrimaryPaneView"
|
||||||
|
Grid.Column="0"
|
||||||
|
Pane="{x:Bind TabViewModel.Primary, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<Border x:Name="Splitter"
|
||||||
|
Grid.Column="1"
|
||||||
|
Background="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
PointerPressed="OnSplitterPointerPressed"
|
||||||
|
PointerMoved="OnSplitterPointerMoved"
|
||||||
|
PointerReleased="OnSplitterPointerReleased"
|
||||||
|
PointerCaptureLost="OnSplitterPointerReleased" />
|
||||||
|
|
||||||
|
<views:ExplorerPaneView x:Name="SecondaryPaneView"
|
||||||
|
Grid.Column="2"
|
||||||
|
Pane="{x:Bind TabViewModel.Secondary, Mode=OneWay}" />
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>一个标签页的宿主:负责单/双窗格布局与中间分隔条。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ContentDialog
|
||||||
|
x:Class="FluidExplorer.Views.SettingsDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="设置"
|
||||||
|
PrimaryButtonText="完成"
|
||||||
|
CloseButtonText="取消"
|
||||||
|
DefaultButton="Primary">
|
||||||
|
|
||||||
|
<ScrollViewer MaxHeight="520">
|
||||||
|
<StackPanel Spacing="16" Width="460">
|
||||||
|
|
||||||
|
<TextBlock Text="外观" Style="{StaticResource BodyStrongTextBlockStyle}" />
|
||||||
|
<ComboBox x:Name="ThemeBox" Header="主题" HorizontalAlignment="Stretch" SelectionChanged="OnThemeChanged">
|
||||||
|
<ComboBoxItem Content="跟随系统" />
|
||||||
|
<ComboBoxItem Content="浅色" />
|
||||||
|
<ComboBoxItem Content="深色" />
|
||||||
|
</ComboBox>
|
||||||
|
<ToggleSwitch x:Name="AnimationSwitch" Header="界面动画" OnContent="使用系统原生动画" OffContent="关闭动画" Toggled="OnAnimationToggled" />
|
||||||
|
|
||||||
|
<TextBlock Text="浏览" Style="{StaticResource BodyStrongTextBlockStyle}" Margin="0,4,0,0" />
|
||||||
|
<ToggleSwitch x:Name="HiddenSwitch" Header="显示隐藏的项目" Toggled="OnHiddenToggled" />
|
||||||
|
<ToggleSwitch x:Name="SystemSwitch" Header="显示受保护的操作系统文件" Toggled="OnSystemToggled" />
|
||||||
|
<ToggleSwitch x:Name="ExtensionSwitch" Header="显示文件扩展名" Toggled="OnExtensionToggled" />
|
||||||
|
<ToggleSwitch x:Name="CheckBoxSwitch" Header="始终显示项目复选框" Toggled="OnCheckBoxToggled" />
|
||||||
|
<ToggleSwitch x:Name="NewTabSwitch" Header="在新标签页中打开文件夹" Toggled="OnNewTabToggled" />
|
||||||
|
|
||||||
|
<TextBlock Text="搜索" Style="{StaticResource BodyStrongTextBlockStyle}" Margin="0,4,0,0" />
|
||||||
|
<ToggleSwitch x:Name="ScopeSwitch" Header="默认仅搜索当前文件夹" OnContent="仅当前文件夹" OffContent="全盘(NTFS 索引)" Toggled="OnScopeToggled" />
|
||||||
|
<ToggleSwitch x:Name="AsYouTypeSwitch" Header="键入即搜索(无需回车)" Toggled="OnAsYouTypeToggled" />
|
||||||
|
<ToggleSwitch x:Name="IndexStartupSwitch" Header="启动时建立索引" Toggled="OnIndexStartupToggled" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="立即建立/重建索引" Click="OnBuildIndexClick" />
|
||||||
|
<Button Content="以管理员身份重启" Click="OnRestartElevatedClick" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock x:Name="IndexStatusText" Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<TextBlock Text="文件操作" Style="{StaticResource BodyStrongTextBlockStyle}" Margin="0,4,0,0" />
|
||||||
|
<ToggleSwitch x:Name="RecycleSwitch" Header="删除时移入回收站"
|
||||||
|
OnContent="移入回收站(可撤销)" OffContent="直接永久删除" Toggled="OnRecycleToggled" />
|
||||||
|
<ComboBox x:Name="ConflictBox" Header="遇到同名文件时" HorizontalAlignment="Stretch" SelectionChanged="OnConflictChanged">
|
||||||
|
<ComboBoxItem Content="每次都询问" />
|
||||||
|
<ComboBoxItem Content="替换目标文件" />
|
||||||
|
<ComboBoxItem Content="跳过" />
|
||||||
|
<ComboBoxItem Content="两个都保留" />
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ContentDialog>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using FluidExplorer.Services;
|
||||||
|
using FluidExplorer.ViewModels;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace FluidExplorer.Views;
|
||||||
|
|
||||||
|
/// <summary>设置对话框:改动即时生效并写入 settings.json。</summary>
|
||||||
|
public sealed partial class SettingsDialog : ContentDialog
|
||||||
|
{
|
||||||
|
private readonly MainViewModel _main;
|
||||||
|
private bool _initializing = true;
|
||||||
|
|
||||||
|
public SettingsDialog(MainViewModel main)
|
||||||
|
{
|
||||||
|
_main = main;
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
ThemeBox.SelectedIndex = main.ThemeMode switch
|
||||||
|
{
|
||||||
|
AppThemeMode.Light => 1,
|
||||||
|
AppThemeMode.Dark => 2,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
AnimationSwitch.IsOn = main.AnimationsEnabled;
|
||||||
|
HiddenSwitch.IsOn = main.ShowHiddenFiles;
|
||||||
|
SystemSwitch.IsOn = main.ShowSystemFiles;
|
||||||
|
ExtensionSwitch.IsOn = main.ShowFileExtensions;
|
||||||
|
CheckBoxSwitch.IsOn = main.AlwaysShowCheckBoxes;
|
||||||
|
NewTabSwitch.IsOn = main.Settings.OpenFoldersInNewTab;
|
||||||
|
ScopeSwitch.IsOn = main.SearchInCurrentFolderOnly;
|
||||||
|
AsYouTypeSwitch.IsOn = main.Settings.SearchAsYouType;
|
||||||
|
IndexStartupSwitch.IsOn = main.Settings.IndexOnStartup;
|
||||||
|
RecycleSwitch.IsOn = main.DeleteToRecycleBin;
|
||||||
|
ConflictBox.SelectedIndex = main.Settings.DefaultConflictPolicy switch
|
||||||
|
{
|
||||||
|
ConflictPolicySetting.Replace => 1,
|
||||||
|
ConflictPolicySetting.Skip => 2,
|
||||||
|
ConflictPolicySetting.KeepBoth => 3,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
IndexStatusText.Text = $"{main.IndexSummary}({Environment.ProcessorCount} 核并行扫描;NTFS 索引可让全盘搜索稳定在毫秒级)";
|
||||||
|
_initializing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnThemeChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.ThemeMode = ThemeBox.SelectedIndex switch
|
||||||
|
{
|
||||||
|
1 => AppThemeMode.Light,
|
||||||
|
2 => AppThemeMode.Dark,
|
||||||
|
_ => AppThemeMode.System
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAnimationToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.AnimationsEnabled = AnimationSwitch.IsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHiddenToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.ShowHiddenFiles = HiddenSwitch.IsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSystemToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.ShowSystemFiles = SystemSwitch.IsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExtensionToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.ShowFileExtensions = ExtensionSwitch.IsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCheckBoxToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.AlwaysShowCheckBoxes = CheckBoxSwitch.IsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNewTabToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.Settings.OpenFoldersInNewTab = NewTabSwitch.IsOn;
|
||||||
|
_main.Settings.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScopeToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.SearchInCurrentFolderOnly = ScopeSwitch.IsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAsYouTypeToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.Settings.SearchAsYouType = AsYouTypeSwitch.IsOn;
|
||||||
|
_main.Settings.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnIndexStartupToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.Settings.IndexOnStartup = IndexStartupSwitch.IsOn;
|
||||||
|
_main.Settings.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRecycleToggled(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.DeleteToRecycleBin = RecycleSwitch.IsOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConflictChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initializing) return;
|
||||||
|
_main.Settings.DefaultConflictPolicy = ConflictBox.SelectedIndex switch
|
||||||
|
{
|
||||||
|
1 => ConflictPolicySetting.Replace,
|
||||||
|
2 => ConflictPolicySetting.Skip,
|
||||||
|
3 => ConflictPolicySetting.KeepBoth,
|
||||||
|
_ => ConflictPolicySetting.Ask
|
||||||
|
};
|
||||||
|
_main.Settings.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBuildIndexClick(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_main.BuildIndexCommand.Execute(null);
|
||||||
|
IndexStatusText.Text = "正在建立索引…(首次读取 MFT 通常几秒内完成,之后靠 USN 增量保持实时)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRestartElevatedClick(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||||
|
=> _main.RestartElevatedCommand.Execute(null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<UserControl
|
||||||
|
x:Class="FluidExplorer.Views.ShellView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:FluidExplorer.ViewModels"
|
||||||
|
xmlns:views="using:FluidExplorer.Views">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
窗口内容放在 UserControl 而不是直接放在 Window 里:WinUI 的 x:Bind 带 Converter 时
|
||||||
|
生成的代码要求绑定根是 FrameworkElement,而 Window 不是,因此 Window 里不放任何 x:Bind。
|
||||||
|
视觉结构对齐 Windows 11 资源管理器:标签栏位于标题栏区域,Mica 由宿主窗口设置。
|
||||||
|
-->
|
||||||
|
<Grid x:Name="RootGrid">
|
||||||
|
<!--
|
||||||
|
标签页刻意不用 TabItemsSource/TabItemTemplate 绑定:TabView 的 TabItemsSource 与
|
||||||
|
TwoWay SelectedItem 组合在增删标签时会触发容器重建异常(实测出现过整个标签栏与内容区消失)。
|
||||||
|
这里由代码手工管理 TabItems,行为完全可控。
|
||||||
|
-->
|
||||||
|
<TabView
|
||||||
|
x:Name="TabHost"
|
||||||
|
IsAddTabButtonVisible="True"
|
||||||
|
TabWidthMode="SizeToContent"
|
||||||
|
CanDragTabs="True"
|
||||||
|
CanReorderTabs="True"
|
||||||
|
AllowDropTabs="True"
|
||||||
|
CloseButtonOverlayMode="OnPointerOver"
|
||||||
|
AddTabButtonClick="OnAddTabButtonClick"
|
||||||
|
TabCloseRequested="OnTabCloseRequested"
|
||||||
|
TabDroppedOutside="OnTabDroppedOutside"
|
||||||
|
SelectionChanged="OnTabSelectionChanged">
|
||||||
|
|
||||||
|
<TabView.TabStripHeader>
|
||||||
|
<Grid Width="8" />
|
||||||
|
</TabView.TabStripHeader>
|
||||||
|
|
||||||
|
<!-- 标签右侧的空白区域 = 窗口拖动区(宿主窗口的 SetTitleBar 指向它) -->
|
||||||
|
<TabView.TabStripFooter>
|
||||||
|
<Grid x:Name="TitleBarDragRegion" MinWidth="120" Height="40" Background="Transparent" />
|
||||||
|
</TabView.TabStripFooter>
|
||||||
|
</TabView>
|
||||||
|
|
||||||
|
<!-- ═══ 文件操作队列(常驻右下角,不打断操作,可展开/收起) ═══ -->
|
||||||
|
<Border x:Name="QueuePanel"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,16,40"
|
||||||
|
Width="420"
|
||||||
|
MaxHeight="360"
|
||||||
|
CornerRadius="8"
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||||
|
Background="{ThemeResource AcrylicInAppFillColorDefaultBrush}"
|
||||||
|
Visibility="{x:Bind ViewModel.IsQueueExpanded, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Grid Grid.Row="0" Padding="12,8" ColumnSpacing="8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Style="{StaticResource BodyStrongTextBlockStyle}" Text="文件操作队列" VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1" Style="{StaticResource ToolbarButtonStyle}" Width="28" Height="24"
|
||||||
|
ToolTipService.ToolTip="清除已完成" Click="OnClearFinishedJobsClick">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphClear}" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" Style="{StaticResource ToolbarButtonStyle}" Width="28" Height="24"
|
||||||
|
ToolTipService.ToolTip="收起" Click="OnCollapseQueueClick">
|
||||||
|
<FontIcon Glyph="" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ListView Grid.Row="1"
|
||||||
|
Padding="4,0,4,8"
|
||||||
|
SelectionMode="None"
|
||||||
|
ItemsSource="{x:Bind ViewModel.JobRows, Mode=OneWay}">
|
||||||
|
<ListView.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:JobRowViewModel">
|
||||||
|
<Grid Padding="8,6" ColumnSpacing="8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Spacing="3">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="{x:Bind Title, Mode=OneWay}" Style="{StaticResource BodyStrongTextBlockStyle}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Text="{x:Bind StateText, Mode=OneWay}"
|
||||||
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
<ProgressBar Value="{x:Bind Progress, Mode=OneWay}" Maximum="1"
|
||||||
|
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}" />
|
||||||
|
<TextBlock Text="{x:Bind ProgressText, Mode=OneWay}"
|
||||||
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
|
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Text="{x:Bind CurrentItem, Mode=OneWay}"
|
||||||
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
|
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" Width="28" Height="28"
|
||||||
|
Tag="{x:Bind}" Click="OnPauseJobClick"
|
||||||
|
Visibility="{x:Bind CanPause, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<FontIcon Glyph="{x:Bind PauseGlyph, Mode=OneWay}" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource ToolbarButtonStyle}" Width="28" Height="28"
|
||||||
|
Tag="{x:Bind}" Click="OnCancelJobClick"
|
||||||
|
Visibility="{x:Bind CanCancel, Mode=OneWay, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<FontIcon Glyph="{StaticResource GlyphStop}" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListView.ItemTemplate>
|
||||||
|
</ListView>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 窗口内容宿主:标签栏(位于标题栏区域)+ 文件操作队列面板。
|
||||||
|
/// 标签容器由代码手工创建与同步,不依赖 TabView 的 ItemsSource 绑定(后者在增删标签时不稳定)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ShellView : UserControl
|
||||||
|
{
|
||||||
|
private readonly Dictionary<ExplorerTabViewModel, TabViewItem> _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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>标题栏拖动区(宿主窗口用 SetTitleBar 指向它)。</summary>
|
||||||
|
public FrameworkElement DragRegion => TitleBarDragRegion;
|
||||||
|
|
||||||
|
/// <summary>供主题切换使用的根元素。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="FluidExplorer.app" />
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10 / 11 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<!-- 本机离线环境:所有依赖已存在于全局包缓存中,禁用在线源以保证还原成功 -->
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
Reference in New Issue
Block a user