Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)

This commit is contained in:
WpyQwq
2026-09-19 11:54:03 +08:00
commit 5780fde61a
60 changed files with 15035 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
using FluidExplorer.Services.Shell;
using FluidExplorer.Services.Search;
using FluidExplorer.Services.FileSystem;
namespace FluidExplorer.Services.Search;
/// <summary>一条搜索结果(对外给 UI 用的扁平结构)。</summary>
public sealed record SearchHit(
string Path,
string Name,
string Directory,
bool IsDirectory,
long Size,
DateTime ModifiedUtc)
{
public string Extension
{
get
{
if (IsDirectory) return string.Empty;
var i = Name.LastIndexOf('.');
return i > 0 && i < Name.Length - 1 ? Name[(i + 1)..].ToLowerInvariant() : string.Empty;
}
}
}
public sealed record SearchOutcome(
IReadOnlyList<SearchHit> Hits,
bool Truncated,
bool UsedIndex,
int IndexedVolumes,
TimeSpan Elapsed,
string? Note = null);
/// <summary>
/// 搜索门面:优先使用 NTFS 索引(毫秒级、全盘),
/// 没有索引时回退为带时间预算的实时枚举(并明确告诉用户"未使用索引")。
/// </summary>
public sealed class SearchService(IFileSystemService fileSystem)
{
private readonly Dictionary<string, IFileIndex> _indexes = new(StringComparer.OrdinalIgnoreCase);
private readonly IFileSystemService _fileSystem = fileSystem;
/// <summary>由 AppServices 注入的具体索引实现工厂(这样本层不依赖具体互操作实现)。</summary>
public Func<string, IFileIndex?>? IndexFactory { get; set; }
public event EventHandler? IndexStateChanged;
public bool HasAnyIndex => _indexes.Count > 0;
public int IndexedVolumeCount => _indexes.Count;
public long TotalIndexedEntries => _indexes.Values.Sum(i => i.EntryCount);
public IndexState AggregateState
{
get
{
if (_indexes.Count == 0) return IndexState.NotStarted;
if (_indexes.Values.Any(i => i.State == IndexState.RequiresElevation)) return IndexState.RequiresElevation;
if (_indexes.Values.Any(i => i.State == IndexState.Building)) return IndexState.Building;
if (_indexes.Values.Any(i => i.State == IndexState.Failed)) return IndexState.Failed;
if (_indexes.Values.All(i => i.State is IndexState.Ready or IndexState.Watching)) return IndexState.Ready;
return IndexState.NotStarted;
}
}
/// <summary>启动指定卷的索引(后台构建,不阻塞 UI)。</summary>
public IFileIndex? EnsureIndex(string volumeRoot)
{
if (_indexes.TryGetValue(volumeRoot, out var existing)) return existing;
var created = IndexFactory?.Invoke(volumeRoot);
if (created is null) return null;
created.StateChanged += (_, _) => IndexStateChanged?.Invoke(this, EventArgs.Empty);
_indexes[volumeRoot] = created;
IndexStateChanged?.Invoke(this, EventArgs.Empty);
return created;
}
public IFileIndex? GetIndex(string volumeRoot)
=> _indexes.TryGetValue(volumeRoot, out var index) ? index : null;
public IReadOnlyCollection<IFileIndex> AllIndexes => _indexes.Values;
/// <summary>为当前所有固定卷建立索引(默认行为:只索引本地固定磁盘,避免扫网络盘)。</summary>
public async Task BuildIndexesAsync(IEnumerable<string> volumeRoots, IProgress<(string Volume, double Progress)>? progress, CancellationToken ct)
{
foreach (var root in volumeRoots)
{
var index = EnsureIndex(root);
if (index is null) continue;
var capturedRoot = root;
var sub = progress is null ? null : new Progress<double>(p => progress.Report((capturedRoot, p)));
await index.BuildAsync(sub, ct).ConfigureAwait(false);
if (index.State is IndexState.Ready or IndexState.Watching) index.StartWatching();
IndexStateChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>执行搜索。Global 走索引;索引不可用时退回实时扫描(并如实告知用户)。</summary>
public async Task<SearchOutcome> SearchAsync(SearchQuery query, SearchScope scope, string? basePath, int maxResults, CancellationToken ct)
{
if (query.IsEmpty) return new SearchOutcome([], false, false, 0, TimeSpan.Zero, "请输入搜索内容");
var sw = System.Diagnostics.Stopwatch.StartNew();
var hits = new List<SearchHit>(maxResults > 0 ? Math.Min(maxResults, 4096) : 1024);
var truncated = false;
var indexedVolumeCount = 0;
if (scope == SearchScope.Global && _indexes.Count > 0)
{
foreach (var (root, index) in _indexes)
{
ct.ThrowIfCancellationRequested();
if (index.State is not (IndexState.Ready or IndexState.Watching)) continue;
indexedVolumeCount++;
var remaining = maxResults - hits.Count;
if (remaining <= 0) { truncated = true; break; }
foreach (var entry in index.Query(query, remaining, ct))
{
var path = entry.FullPath;
if (path is null && !index.TryResolvePath(entry.Frn, out path)) continue;
if (!MatchesExtendedFilters(entry, query, path!)) continue;
hits.Add(new SearchHit(
path!,
entry.Name,
PathHelper.GetParent(path!),
entry.IsDirectory,
entry.Size,
entry.ModifiedUtc == default ? DateTime.MinValue : DateTime.SpecifyKind(entry.ModifiedUtc, DateTimeKind.Utc)));
if (hits.Count >= maxResults) { truncated = true; break; }
}
}
if (indexedVolumeCount > 0)
{
sw.Stop();
return new SearchOutcome(hits, truncated, true, indexedVolumeCount, sw.Elapsed);
}
// 一个可用索引都没有(未建、无管理员权限、非 NTFS):不要返回空结果,
// 而是退回实时扫描,并明确告诉用户为什么慢、怎么才能变快。
}
// 回退:实时枚举(限定范围 + 时间预算 + 结果上限,绝不卡住界面)
var scanRoot = !string.IsNullOrEmpty(basePath) && Directory.Exists(basePath)
? basePath
: SafeSystemDriveRoot();
if (!string.IsNullOrEmpty(scanRoot))
{
var options = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = 0,
MaxRecursionDepth = 32
};
await Task.Run(() =>
{
var budget = TimeSpan.FromSeconds(20);
foreach (var path in Directory.EnumerateFileSystemEntries(scanRoot, "*", options))
{
ct.ThrowIfCancellationRequested();
if (sw.Elapsed > budget) { truncated = true; break; }
string name = PathHelper.GetName(path);
if (!MatchesNameOnly(name, query)) continue;
try
{
var isDir = Directory.Exists(path);
var info = isDir ? null : new FileInfo(path);
hits.Add(new SearchHit(path, name, PathHelper.GetParent(path), isDir,
info?.Length ?? 0, info?.LastWriteTimeUtc ?? DateTime.MinValue));
}
catch
{
hits.Add(new SearchHit(path, name, PathHelper.GetParent(path), false, 0, DateTime.MinValue));
}
if (hits.Count >= maxResults) { truncated = true; break; }
}
}, ct).ConfigureAwait(false);
}
sw.Stop();
var note = _indexes.Count == 0
? "未启用 NTFS 索引,本次为实时扫描(可在设置中开启索引以获得毫秒级全盘搜索)"
: $"索引尚未就绪,已在「{scanRoot}」实时扫描。以管理员身份重启可建立 NTFS 索引,全盘搜索将提升到毫秒级";
return new SearchOutcome(hits, truncated, false, 0, sw.Elapsed, note);
}
private static string? SafeSystemDriveRoot()
{
try
{
var root = Path.GetPathRoot(Environment.SystemDirectory);
return string.IsNullOrEmpty(root) ? null : root;
}
catch
{
return null;
}
}
private static bool MatchesExtendedFilters(IndexedEntry entry, SearchQuery query, string path)
{
if (query.MinSize is { } min && entry.Size >= 0 && entry.Size < min) return false;
if (query.MaxSize is { } max && entry.Size >= 0 && entry.Size > max) return false;
if (!string.IsNullOrEmpty(query.PathFilter))
{
if (!path.Contains(query.PathFilter, StringComparison.OrdinalIgnoreCase)) return false;
}
if (query.MatchWholePath && query.IncludeTerms.Count > 0)
{
// 名字里已经命中就不必再看路径;名字没命中的,允许整条路径命中
foreach (var term in query.IncludeTerms)
{
if (entry.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) continue;
if (path.Contains(term, StringComparison.OrdinalIgnoreCase)) continue;
return false;
}
}
return true;
}
private static bool MatchesNameOnly(string name, SearchQuery query)
{
foreach (var term in query.IncludeTerms)
if (!name.Contains(term, StringComparison.OrdinalIgnoreCase)) return false;
foreach (var bad in query.ExcludeTerms)
if (name.Contains(bad, StringComparison.OrdinalIgnoreCase)) return false;
if (query.Extensions.Count > 0)
{
var ext = Path.GetExtension(name).TrimStart('.').ToLowerInvariant();
if (!query.Extensions.Contains(ext)) return false;
}
return true;
}
}