Forge
csharp4405de34
1#nullable enable
2using System.Text.Json;
3using System.Text.Json.Serialization;
4using CascadeIDE.Features.UiChrome;
5using CascadeIDE.Models;
6
7namespace CascadeIDE.Services;
8
9/// <summary>
10/// Шипнутые пресеты: по умолчанию — встроенный ресурс (и опционально файл рядом с exe); затем overlay репозитория
11/// <c>.cascade/workspace.toml</c> (<c>[[code_navigation.presets]]</c>); затем пользовательский <c>settings.toml</c>.
12/// Merge по <see cref="CodeNavigationPresetEntry.Id"/> на каждом шаге (последний слой побеждает).
13/// Внутренняя цепочка <see cref="CodeNavigationPresetMerge"/> по-прежнему получает JSON-строку (контракт merge не менялся).
14/// </summary>
15public static class CodeNavigationPresetsLoader
16{
17 private static readonly JsonSerializerOptions s_mergeJson = new() { WriteIndented = false };
18
19 /// <summary>Относительный путь от <see cref="AppContext.BaseDirectory"/> (опциональный override поверх встроенного бандла).</summary>
20 public const string BundledRelativePath = "CodeNavigation/presets.toml";
21
22 /// <summary>Корень шипнутого TOML (<c>[code_navigation]</c> / <c>[[code_navigation.presets]]</c>).</summary>
23 private sealed class BundledPresetsRoot
24 {
25 public CodeNavigationSettings? CodeNavigation { get; set; }
26 }
27
28 private sealed class PresetMergeWire
29 {
30 [JsonPropertyName("include_kinds")]
31 public List<string>? IncludeKinds { get; set; }
32
33 [JsonPropertyName("exclude_kinds")]
34 public List<string>? ExcludeKinds { get; set; }
35 }
36
37 /// <summary>
38 /// Текст шипнутого <c>CodeNavigation/presets.toml</c>: как в рантайме — сначала файл под <see cref="AppContext.BaseDirectory"/>,
39 /// иначе <see cref="BundledAppContent"/> (EmbeddedResource). Так тесты и агенты не зависят от устаревшей копии <c>CascadeIDE.dll</c> без ресурсов.
40 /// </summary>
41 /// <exception cref="InvalidOperationException">Нет ни файла рядом с процессом, ни встроенного ресурса.</exception>
42 public static string GetEmbeddedBundledPresetsToml()
43 {
44 if (!BundledAppContent.TryReadDiskThenEmbedded(BundledRelativePath, out var text) || string.IsNullOrWhiteSpace(text))
45 throw new InvalidOperationException(
46 $"Missing bundled {BundledRelativePath} (disk under AppContext.BaseDirectory or embedded resource in CascadeIDE assembly).");
47 return text;
48 }
49
50 /// <summary>
51 /// Тот же JSON, что внутри уходит в <see cref="CodeNavigationPresetMerge.Merge"/> после разбора бандла без пользовательского overlay.
52 /// </summary>
53 public static string ToPresetMergeJsonFromBundledToml(string bundledToml)
54 {
55 var root = CascadeTomlSerializer.Deserialize<BundledPresetsRoot>(bundledToml.Trim());
56 if (root?.CodeNavigation?.Presets is not { Count: > 0 })
57 return "{}";
58
59 var merged = MergeBundledWithUser(root.CodeNavigation.Presets, []);
60 return PresetEntriesToMergeJson(merged);
61 }
62
63 /// <summary>JSON для <see cref="CodeNavigationPresetMerge"/>: бандл → репо → пользовательские настройки.</summary>
64 /// <param name="settings">Из <c>%LocalAppData%\CascadeIDE\settings.toml</c>.</param>
65 /// <param name="solutionPath">Путь к <c>.sln</c> или к каталогу репозитория; для <c>.cascade/workspace.toml</c>. Пусто — только бандл + пользователь.</param>
66 public static string GetEffectivePresetsJson(CodeNavigationSettings settings, string? solutionPath = null)
67 {
68 var bundled = LoadBundledEntriesOrFallback();
69 var repository = LoadRepositoryPresetsFromSolutionDirectory(solutionPath);
70 var afterRepo = MergeBundledWithUser(bundled, repository);
71 var merged = MergeBundledWithUser(afterRepo, settings.Presets);
72 return PresetEntriesToMergeJson(merged);
73 }
74
75 /// <summary>Читает <c>.cascade/workspace.toml</c> в корне репозитория; возвращает пресеты навигации или пустой список.</summary>
76 /// <param name="solutionPath">Путь к <c>.sln</c> или к каталогу репозитория.</param>
77 public static IReadOnlyList<CodeNavigationPresetEntry> LoadRepositoryPresetsFromSolutionDirectory(string? solutionPath)
78 {
79 var dir = NormalizeRepositoryRoot(solutionPath);
80 if (dir is null)
81 return [];
82
83 try
84 {
85 var path = Path.Combine(dir, ".cascade", "workspace.toml");
86 if (!File.Exists(path))
87 return [];
88
89 var text = File.ReadAllText(path);
90 var ui = CascadeTomlSerializer.Deserialize<Features.Workspace.RepositoryWorkspaceToml>(text);
91 if (ui?.CodeNavigation?.Presets is not { Count: > 0 })
92 return [];
93
94 return ui.CodeNavigation.Presets;
95 }
96 catch
97 {
98 return [];
99 }
100 }
101
102 /// <summary>Каталог репозитория: для пути к файлу решения — родительская папка (как <c>GetWorkspacePath</c> в VM).</summary>
103 private static string? NormalizeRepositoryRoot(string? solutionPath)
104 {
105 if (string.IsNullOrWhiteSpace(solutionPath))
106 return null;
107 try
108 {
109 var p = CanonicalFilePath.Normalize(solutionPath.Trim());
110 if (File.Exists(p))
111 return Path.GetDirectoryName(p);
112 return Directory.Exists(p) ? p : null;
113 }
114 catch
115 {
116 return null;
117 }
118 }
119
120 public static IReadOnlyList<CodeNavigationPresetEntry> LoadBundledEntriesOrFallback()
121 {
122 if (!BundledAppContent.TryReadDiskThenEmbedded(BundledRelativePath, out var raw))
123 return [];
124 try
125 {
126 var root = CascadeTomlSerializer.Deserialize<BundledPresetsRoot>(raw.Trim());
127 if (root?.CodeNavigation?.Presets is { Count: > 0 })
128 return root.CodeNavigation.Presets;
129 }
130 catch
131 {
132 // ignored
133 }
134
135 return [];
136 }
137
138 /// <summary>Пользовательские пресеты перекрывают бандл по совпадающему <see cref="CodeNavigationPresetEntry.Id"/>.</summary>
139 public static List<CodeNavigationPresetEntry> MergeBundledWithUser(
140 IReadOnlyList<CodeNavigationPresetEntry> bundled,
141 IReadOnlyList<CodeNavigationPresetEntry> user)
142 {
143 var dict = new Dictionary<string, CodeNavigationPresetEntry>(StringComparer.OrdinalIgnoreCase);
144 foreach (var e in bundled)
145 {
146 if (string.IsNullOrWhiteSpace(e.Id))
147 continue;
148 dict[e.Id.Trim()] = CloneEntry(e);
149 }
150
151 foreach (var e in user)
152 {
153 if (string.IsNullOrWhiteSpace(e.Id))
154 continue;
155 dict[e.Id.Trim()] = CloneEntry(e);
156 }
157
158 return dict.Values.OrderBy(x => x.Id, StringComparer.Ordinal).ToList();
159 }
160
161 private static CodeNavigationPresetEntry CloneEntry(CodeNavigationPresetEntry e) =>
162 new()
163 {
164 Id = e.Id,
165 IncludeKinds = e.IncludeKinds?.ToList(),
166 ExcludeKinds = e.ExcludeKinds?.ToList()
167 };
168
169 private static string PresetEntriesToMergeJson(IReadOnlyList<CodeNavigationPresetEntry> merged)
170 {
171 var dict = new Dictionary<string, PresetMergeWire>(StringComparer.OrdinalIgnoreCase);
172 foreach (var e in merged)
173 {
174 if (string.IsNullOrWhiteSpace(e.Id))
175 continue;
176 dict[e.Id.Trim()] = new PresetMergeWire
177 {
178 IncludeKinds = e.IncludeKinds,
179 ExcludeKinds = e.ExcludeKinds
180 };
181 }
182
183 return JsonSerializer.Serialize(dict, s_mergeJson);
184 }
185}
186
View only · write via MCP/CIDE