| 1 | #nullable enable |
| 2 | using System.Collections.ObjectModel; |
| 3 | using CascadeIDE.Models; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Workspace.Application; |
| 6 | |
| 7 | /// <summary>Единый индекс путей solution/workspace (ADR 0167): slash, Go to File, фильтр SE.</summary> |
| 8 | public sealed class WorkspaceFileIndex |
| 9 | { |
| 10 | private readonly List<WorkspaceFileEntry> _entries = []; |
| 11 | private string? _solutionPath; |
| 12 | private string _workspaceRoot = ""; |
| 13 | |
| 14 | public void Invalidate( |
| 15 | ObservableCollection<SolutionItem> roots, |
| 16 | string? solutionPath, |
| 17 | string workspaceRoot) |
| 18 | { |
| 19 | _solutionPath = solutionPath; |
| 20 | _workspaceRoot = workspaceRoot.Trim(); |
| 21 | _entries.Clear(); |
| 22 | |
| 23 | if (roots.Count == 0) |
| 24 | return; |
| 25 | |
| 26 | var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| 27 | foreach (var (title, fullPath) in McpSolutionTree.CollectFileEntries(roots)) |
| 28 | { |
| 29 | if (!TryToInsertPath(fullPath, out var insert)) |
| 30 | continue; |
| 31 | if (!seen.Add(insert)) |
| 32 | continue; |
| 33 | _entries.Add(new WorkspaceFileEntry(title, fullPath, insert, insert)); |
| 34 | } |
| 35 | |
| 36 | foreach (var projectPath in McpSolutionTree.CollectProjectPaths(roots)) |
| 37 | { |
| 38 | if (!TryToInsertPath(projectPath, out var insert)) |
| 39 | continue; |
| 40 | if (!seen.Add(insert)) |
| 41 | continue; |
| 42 | var name = Path.GetFileName(projectPath); |
| 43 | _entries.Add(new WorkspaceFileEntry(name, projectPath, insert, name)); |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | public IReadOnlyList<WorkspaceFileMatch> Search(string query, int limit) |
| 48 | { |
| 49 | if (limit <= 0) |
| 50 | return []; |
| 51 | |
| 52 | var prefix = NormalizeQuery(query); |
| 53 | IEnumerable<(WorkspaceFileEntry Entry, int Rank)> ranked = _entries |
| 54 | .Select(e => (e, Rank(prefix, e.InsertPath, e.Title, e.FullPath))); |
| 55 | |
| 56 | if (prefix.Length > 0) |
| 57 | ranked = ranked.Where(x => x.Rank < int.MaxValue); |
| 58 | |
| 59 | return ranked |
| 60 | .OrderBy(x => x.Rank) |
| 61 | .ThenBy(x => x.Entry.InsertPath, StringComparer.OrdinalIgnoreCase) |
| 62 | .Take(limit) |
| 63 | .Select(x => new WorkspaceFileMatch( |
| 64 | x.Entry.Title, |
| 65 | x.Entry.FullPath, |
| 66 | x.Entry.InsertPath, |
| 67 | x.Entry.Help, |
| 68 | x.Rank)) |
| 69 | .ToList(); |
| 70 | } |
| 71 | |
| 72 | public HashSet<string> MatchingFullPaths(string query) |
| 73 | { |
| 74 | var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| 75 | foreach (var m in Search(query, int.MaxValue)) |
| 76 | set.Add(m.FullPath); |
| 77 | return set; |
| 78 | } |
| 79 | |
| 80 | private bool TryToInsertPath(string fullPath, out string insertPath) |
| 81 | { |
| 82 | insertPath = ""; |
| 83 | var relative = McpSolutionTree.GetRelativePath(_solutionPath, fullPath); |
| 84 | if (!string.IsNullOrWhiteSpace(relative)) |
| 85 | { |
| 86 | insertPath = relative.Replace('\\', '/'); |
| 87 | return true; |
| 88 | } |
| 89 | |
| 90 | if (string.IsNullOrWhiteSpace(_workspaceRoot)) |
| 91 | return false; |
| 92 | |
| 93 | try |
| 94 | { |
| 95 | var rootFull = Path.GetFullPath(_workspaceRoot); |
| 96 | var fileFull = Path.GetFullPath(fullPath); |
| 97 | if (!fileFull.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) |
| 98 | return false; |
| 99 | |
| 100 | insertPath = Path.GetRelativePath(rootFull, fileFull).Replace('\\', '/'); |
| 101 | return insertPath.Length > 0; |
| 102 | } |
| 103 | catch |
| 104 | { |
| 105 | return false; |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | internal static string NormalizeQuery(string query) => |
| 110 | query.Trim().Replace('\\', '/'); |
| 111 | |
| 112 | internal static int Rank(string prefix, string insertPath, string title, string fullPath) |
| 113 | { |
| 114 | if (prefix.Length == 0) |
| 115 | return 0; |
| 116 | |
| 117 | if (insertPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) |
| 118 | || title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) |
| 119 | return 0; |
| 120 | |
| 121 | if (insertPath.Contains(prefix, StringComparison.OrdinalIgnoreCase) |
| 122 | || title.Contains(prefix, StringComparison.OrdinalIgnoreCase) |
| 123 | || fullPath.Contains(prefix, StringComparison.OrdinalIgnoreCase)) |
| 124 | return 1; |
| 125 | |
| 126 | return int.MaxValue; |
| 127 | } |
| 128 | |
| 129 | private sealed record WorkspaceFileEntry(string Title, string FullPath, string InsertPath, string Help); |
| 130 | } |
| 131 | |
| 132 | public readonly record struct WorkspaceFileMatch( |
| 133 | string Title, |
| 134 | string FullPath, |
| 135 | string InsertPath, |
| 136 | string Help, |
| 137 | int Rank); |
| 138 | |