Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user