Forge
csharpdeeb25a2
1#nullable enable
2using System.Collections.ObjectModel;
3using CascadeIDE.Models;
4
5namespace CascadeIDE.Features.Workspace.Application;
6
7/// <summary>Единый индекс путей solution/workspace (ADR 0167): slash, Go to File, фильтр SE.</summary>
8public 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
132public readonly record struct WorkspaceFileMatch(
133 string Title,
134 string FullPath,
135 string InsertPath,
136 string Help,
137 int Rank);
138
View only · write via MCP/CIDE