238 lines
10 KiB
C#
238 lines
10 KiB
C#
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;
|
|
}
|
|
}
|