Forge
csharpdeeb25a2
1using System.Xml.Linq;
2using CascadeIDE.Models;
3using CascadeIDE.Services;
4using CascadeIDE.Contracts;
5
6namespace CascadeIDE.Features.Workspace.DataAcquisition;
7
8/// <summary>
9/// Дерево файлов проекта для обозревателя: разбор .csproj (Compile/None/…), SDK-glob,
10/// <see cref="DependentUpon"/> и эвристика partial-классов. Не занимается загрузкой .sln.
11/// </summary>
12[IoBoundary]
13public static class ProjectFileTreeBuilder
14{
15 /// <param name="solutionBaseDirectoryForIgnore">Каталог с <c>.sln</c> — для поиска корня репозитория и загрузки <c>.gitignore</c> / <c>.cascadeignore</c>.</param>
16 public static void AddProjectFileChildren(SolutionItem projectNode, string projectPath, string? solutionBaseDirectoryForIgnore = null)
17 {
18 if (!File.Exists(projectPath))
19 return;
20
21 var projectDir = Path.GetDirectoryName(projectPath) ?? "";
22 var hintForRepo = string.IsNullOrWhiteSpace(solutionBaseDirectoryForIgnore)
23 ? projectDir
24 : solutionBaseDirectoryForIgnore.Trim();
25 var repoRoot = WorkspaceIgnoreMatcher.ResolveRepositoryRoot(hintForRepo);
26 var ignore = WorkspaceIgnoreMatcher.GetOrCreate(repoRoot);
27 var fileEntries = new List<(string RelativePath, string FullPath)>();
28
29 try
30 {
31 using var stream = File.OpenRead(projectPath);
32 var doc = XDocument.Load(stream);
33
34 var included = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
35 var explicitNesting = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
36
37 foreach (var itemGroup in doc.Descendants().Where(e => e.Name.LocalName == "ItemGroup"))
38 {
39 foreach (var item in itemGroup.Elements())
40 {
41 var localName = item.Name.LocalName;
42 if (localName != "Compile" && localName != "None" && localName != "Page" && localName != "AvaloniaResource")
43 continue;
44
45 var include = (string?)item.Attribute("Include") ?? (string?)item.Attribute("Update");
46 if (string.IsNullOrWhiteSpace(include))
47 continue;
48
49 var ext = Path.GetExtension(include);
50 if (!string.Equals(ext, ".cs", StringComparison.OrdinalIgnoreCase) &&
51 !string.Equals(ext, ".axaml", StringComparison.OrdinalIgnoreCase) &&
52 !string.Equals(ext, ".xaml", StringComparison.OrdinalIgnoreCase) &&
53 !string.Equals(ext, ".md", StringComparison.OrdinalIgnoreCase) &&
54 !string.Equals(ext, ".markdown", StringComparison.OrdinalIgnoreCase))
55 continue;
56
57 var normalizedInclude = include.Replace('\\', Path.DirectorySeparatorChar);
58 var fullPath = CanonicalFilePath.Normalize(Path.Combine(projectDir, normalizedInclude));
59 if (ignore.IsIgnored(fullPath))
60 continue;
61 if (item.Attribute("Include") is not null &&
62 included.Add(fullPath) && File.Exists(fullPath))
63 fileEntries.Add((normalizedInclude, fullPath));
64
65 var depEl = item.Elements().FirstOrDefault(e => e.Name.LocalName == "DependentUpon");
66 var depRaw = depEl?.Value.Trim();
67 if (string.IsNullOrEmpty(depRaw) || !File.Exists(fullPath))
68 continue;
69 var parentFull = ResolveDependentUponTarget(projectDir, normalizedInclude, depRaw);
70 if (parentFull is not null && File.Exists(parentFull) &&
71 !string.Equals(fullPath, parentFull, StringComparison.OrdinalIgnoreCase))
72 explicitNesting[fullPath] = parentFull;
73 }
74 }
75
76 // SDK-стиль: в XML часто нет явных Compile — сканируем каталог
77 if (fileEntries.Count == 0)
78 {
79 foreach (var f in Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories)
80 .Concat(Directory.EnumerateFiles(projectDir, "*.axaml", SearchOption.AllDirectories))
81 .Concat(Directory.EnumerateFiles(projectDir, "*.xaml", SearchOption.AllDirectories))
82 .Concat(Directory.EnumerateFiles(projectDir, "*.md", SearchOption.AllDirectories))
83 .Concat(Directory.EnumerateFiles(projectDir, "*.markdown", SearchOption.AllDirectories)))
84 {
85 var rel = Path.GetRelativePath(projectDir, f);
86 if (rel.StartsWith("obj", StringComparison.OrdinalIgnoreCase) ||
87 rel.StartsWith("bin", StringComparison.OrdinalIgnoreCase))
88 continue;
89 var fp = CanonicalFilePath.Normalize(f);
90 if (ignore.IsIgnored(fp))
91 continue;
92 if (!included.Add(fp))
93 continue;
94 fileEntries.Add((rel, fp));
95 }
96 }
97
98 var nestingPairs = MergeNestingPairs(fileEntries, explicitNesting);
99 AddFileEntriesAsTree(projectNode, fileEntries);
100 ApplyDependentNesting(projectNode, nestingPairs);
101 }
102 catch
103 {
104 // Оставляем проект без дочерних файлов
105 }
106 }
107
108 /// <summary>Сортировка узлов дерева решения/проекта: папки, затем файлы по имени (рекурсивно).</summary>
109 public static void SortSolutionItemChildren(SolutionItem node, StringComparer comparer)
110 {
111 var list = node.Children;
112 if (list.Count == 0)
113 return;
114 var ordered = list
115 .OrderBy(c => c.FullPath is not null ? 1 : 0)
116 .ThenBy(c => c.Title, comparer)
117 .ToList();
118 list.Clear();
119 foreach (var c in ordered)
120 {
121 list.Add(c);
122 SortSolutionItemChildren(c, comparer);
123 }
124 }
125
126 /// <summary>
127 /// Целевой файл родителя для <see cref="DependentUpon"/> в .csproj (как в Visual Studio).
128 /// </summary>
129 private static string? ResolveDependentUponTarget(string projectDir, string childProjectRelativePath, string dependentUpon)
130 {
131 dependentUpon = dependentUpon.Replace('/', Path.DirectorySeparatorChar).Trim();
132 if (dependentUpon.Length == 0)
133 return null;
134
135 if (dependentUpon.Contains(Path.DirectorySeparatorChar))
136 return CanonicalFilePath.Normalize(Path.Combine(projectDir, dependentUpon));
137
138 var childDir = Path.GetDirectoryName(childProjectRelativePath);
139 if (string.IsNullOrEmpty(childDir))
140 return CanonicalFilePath.Normalize(Path.Combine(projectDir, dependentUpon));
141 return CanonicalFilePath.Normalize(Path.Combine(projectDir, childDir, dependentUpon));
142 }
143
144 /// <summary>
145 /// Для SDK-glob: вложить <c>Stem.Rest.cs</c> под самый длинный существующий <c>Stem.cs</c> в той же папке (partial-классы).
146 /// Явные пары из .csproj имеют приоритет.
147 /// </summary>
148 private static List<(string ChildFullPath, string ParentFullPath)> MergeNestingPairs(
149 List<(string RelativePath, string FullPath)> fileEntries,
150 Dictionary<string, string> explicitNesting)
151 {
152 var comparer = StringComparer.OrdinalIgnoreCase;
153 var pairs = new List<(string, string)>();
154 foreach (var kv in explicitNesting)
155 pairs.Add((kv.Key, kv.Value));
156
157 var explicitChild = new HashSet<string>(explicitNesting.Keys, comparer);
158 var byDir = fileEntries.GroupBy(e => Path.GetDirectoryName(e.FullPath) ?? "", comparer);
159 foreach (var group in byDir)
160 {
161 var names = new HashSet<string>(
162 group.Select(g => Path.GetFileName(g.FullPath)),
163 StringComparer.OrdinalIgnoreCase);
164
165 foreach (var (_, full) in group)
166 {
167 if (explicitChild.Contains(full))
168 continue;
169 if (!full.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
170 continue;
171 var parentName = TryInferParentCsFileName(Path.GetFileName(full), names);
172 if (parentName is null)
173 continue;
174 var parentFull = CanonicalFilePath.Normalize(Path.Combine(group.Key, parentName));
175 if (!File.Exists(parentFull) || comparer.Equals(full, parentFull))
176 continue;
177 pairs.Add((full, parentFull));
178 }
179 }
180
181 return pairs;
182 }
183
184 private static string? TryInferParentCsFileName(string fileName, HashSet<string> fileNamesInSameDir)
185 {
186 var stem = Path.GetFileNameWithoutExtension(fileName);
187 if (string.IsNullOrEmpty(stem))
188 return null;
189 var parts = stem.Split('.');
190 if (parts.Length < 2)
191 return null;
192 for (var i = parts.Length - 1; i >= 1; i--)
193 {
194 var candidate = string.Join(".", parts.Take(i)) + ".cs";
195 if (fileNamesInSameDir.Contains(candidate))
196 return candidate;
197 }
198 return null;
199 }
200
201 private static void ApplyDependentNesting(SolutionItem projectNode, List<(string ChildFullPath, string ParentFullPath)> pairs)
202 {
203 var comparer = StringComparer.OrdinalIgnoreCase;
204 var seenChild = new HashSet<string>(comparer);
205 foreach (var (childPath, parentPath) in pairs
206 .OrderByDescending(p => p.ChildFullPath.Length))
207 {
208 if (!seenChild.Add(childPath))
209 continue;
210 if (comparer.Equals(childPath, parentPath))
211 continue;
212 if (!TryRemoveFileNodeByFullPath(projectNode, childPath, out var childNode) || childNode is null)
213 continue;
214 var parentNode = FindFileNodeByFullPath(projectNode, parentPath);
215 if (parentNode is null)
216 continue;
217 if (IsDescendantOf(childNode, parentPath, comparer))
218 continue;
219 parentNode.Children.Add(childNode);
220 }
221
222 SortSolutionItemChildren(projectNode, comparer);
223 }
224
225 private static bool IsDescendantOf(SolutionItem node, string ancestorFullPath, StringComparer comparer)
226 {
227 foreach (var c in node.Children)
228 {
229 if (c.FullPath is not null && comparer.Equals(c.FullPath, ancestorFullPath))
230 return true;
231 if (IsDescendantOf(c, ancestorFullPath, comparer))
232 return true;
233 }
234 return false;
235 }
236
237 private static bool TryRemoveFileNodeByFullPath(SolutionItem root, string fullPath, out SolutionItem? removed)
238 {
239 var comparer = StringComparer.OrdinalIgnoreCase;
240 removed = null;
241 for (var i = 0; i < root.Children.Count; i++)
242 {
243 var c = root.Children[i];
244 if (c.FullPath is not null && comparer.Equals(c.FullPath, fullPath))
245 {
246 removed = c;
247 root.Children.RemoveAt(i);
248 return true;
249 }
250
251 if (TryRemoveFileNodeByFullPath(c, fullPath, out removed))
252 return true;
253 }
254
255 return false;
256 }
257
258 private static SolutionItem? FindFileNodeByFullPath(SolutionItem root, string fullPath)
259 {
260 var comparer = StringComparer.OrdinalIgnoreCase;
261 if (root.FullPath is not null && comparer.Equals(root.FullPath, fullPath))
262 return root;
263 foreach (var c in root.Children)
264 {
265 var found = FindFileNodeByFullPath(c, fullPath);
266 if (found is not null)
267 return found;
268 }
269
270 return null;
271 }
272
273 /// <summary>Добавляет файлы в дерево проекта: папки по относительному пути, единый порядок (папки, затем файлы по имени).</summary>
274 private static void AddFileEntriesAsTree(SolutionItem projectNode, List<(string RelativePath, string FullPath)> fileEntries)
275 {
276 var comparer = StringComparer.OrdinalIgnoreCase;
277 foreach (var (relativePath, fullPath) in fileEntries)
278 {
279 var parts = relativePath.Split(Path.DirectorySeparatorChar, '/').Where(p => p.Length > 0).ToList();
280 if (parts.Count == 0)
281 continue;
282 if (parts.Count == 1)
283 {
284 projectNode.Children.Add(SolutionItem.CreateFile(parts[0], fullPath));
285 continue;
286 }
287 SolutionItem current = projectNode;
288 for (var i = 0; i < parts.Count - 1; i++)
289 {
290 var segment = parts[i];
291 var folder = current.Children.FirstOrDefault(c => c.FullPath is null && comparer.Equals(c.Title, segment));
292 if (folder is null)
293 {
294 folder = SolutionItem.CreateFolder(segment);
295 current.Children.Add(folder);
296 }
297 current = folder;
298 }
299 current.Children.Add(SolutionItem.CreateFile(parts[^1], fullPath));
300 }
301 SortSolutionItemChildren(projectNode, comparer);
302 }
303}
304
View only · write via MCP/CIDE