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