Forge
csharp4405de34
1#nullable enable
2using System.ComponentModel;
3using System.Diagnostics;
4using Avalonia.Threading;
5using CascadeIDE.Features.Workspace;
6using CascadeIDE.Features.Forge.Lens;
7using CascadeIDE.Features.WorkspaceNavigation.Application;
8using CascadeIDE.Models;
9using CascadeIDE.Features.Forge.Infrastructure;
10using CascadeIDE.Services.Intercom;
11using CascadeIDE.Services.MarkdownPreview;
12using CommunityToolkit.Mvvm.ComponentModel;
13
14namespace CascadeIDE.ViewModels;
15
16/// <summary>Общий state/payload host для MFD page и отдельного окна preview.</summary>
17public abstract partial class MarkdownPreviewSurfaceViewModel : ObservableObject, IDisposable
18{
19 private readonly MarkdownPreviewPayloadBuilder _payloadBuilder = new();
20 private MainWindowViewModel? _editorVm;
21 private PropertyChangedEventHandler? _editorHandler;
22 private CancellationTokenSource? _refreshCts;
23 private int? _pendingScrollLine;
24 private string? _pendingScrollFragment;
25
26 [ObservableProperty]
27 private string _title = "Markdown Preview";
28
29 [ObservableProperty]
30 private string _statusText = "Open a Markdown document to preview.";
31
32 [ObservableProperty]
33 private bool _isBusy;
34
35 [ObservableProperty]
36 private string _errorMessage = "";
37
38 [ObservableProperty]
39 private MarkdownPreviewPayload? _payload;
40
41 public void AttachToEditor(MainWindowViewModel vm)
42 {
43 if (ReferenceEquals(_editorVm, vm))
44 {
45 RefreshFromEditor();
46 return;
47 }
48
49 DetachFromEditor();
50 _editorVm = vm;
51 _editorHandler = (_, e) =>
52 {
53 if (e.PropertyName is nameof(MainWindowViewModel.CurrentFilePath)
54 or nameof(MainWindowViewModel.EditorText)
55 or nameof(MainWindowViewModel.MarkdownKrokiEnabled)
56 or nameof(MainWindowViewModel.MarkdownKrokiBaseUrl))
57 {
58 RefreshFromEditor();
59 }
60 };
61 vm.PropertyChanged += _editorHandler;
62 RefreshFromEditor();
63 }
64
65 public void DetachFromEditor()
66 {
67 _refreshCts?.Cancel();
68 _refreshCts?.Dispose();
69 _refreshCts = null;
70 if (_editorVm is not null && _editorHandler is not null)
71 _editorVm.PropertyChanged -= _editorHandler;
72 _editorVm = null;
73 _editorHandler = null;
74 }
75
76 public void SetContent(string title, string content, string? sourcePath = null, int? scrollToLine = null)
77 {
78 if (scrollToLine is > 0)
79 _pendingScrollLine = scrollToLine;
80
81 QueueRefresh(
82 new MarkdownPreviewSource(title, content ?? "", sourcePath),
83 SettingsService.Load(),
84 emptyMessage: "Markdown content is empty.");
85 }
86
87 /// <summary>Единый обработчик ссылок preview: http, .md, #fragment, code-anchor.</summary>
88 public void TryOpenPreviewLink(string linkUrl, MarkdownPreviewAnchorRegistry? anchors)
89 {
90 if (string.IsNullOrWhiteSpace(linkUrl))
91 return;
92
93 var trimmed = linkUrl.Trim();
94 if (trimmed.StartsWith(MarkdownCodeAnchorPreviewExpander.UriScheme, StringComparison.OrdinalIgnoreCase))
95 {
96 TryOpenCodeAnchor(trimmed);
97 return;
98 }
99
100 if (trimmed.StartsWith(MarkdownCodeAnchorPreviewExpander.ForgeUriScheme, StringComparison.OrdinalIgnoreCase))
101 {
102 TryOpenForgeArtifact(trimmed);
103 return;
104 }
105
106 if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
107 || trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
108 {
109 TryOpenExternalUrl(trimmed);
110 return;
111 }
112
113 if (trimmed.StartsWith('#'))
114 {
115 anchors?.ScrollToFragment(trimmed[1..]);
116 return;
117 }
118
119 var (path, fragment) = MarkdownPreviewRenderContext.SplitUrl(trimmed);
120 if (!string.IsNullOrWhiteSpace(path))
121 TryOpenLinkedDocument(path);
122
123 if (!string.IsNullOrWhiteSpace(fragment))
124 anchors?.ScrollToFragment(fragment);
125 }
126
127 /// <summary>Открыть связанный markdown (ADR/KB) в этом же preview.</summary>
128 public void TryOpenLinkedDocument(string linkUrl)
129 {
130 var ws = TryGetWorkspaceRoot();
131 if (string.IsNullOrWhiteSpace(ws))
132 return;
133
134 var (path, fragment) = MarkdownPreviewRenderContext.SplitUrl(linkUrl);
135 if (string.IsNullOrWhiteSpace(path))
136 {
137 if (!string.IsNullOrWhiteSpace(fragment))
138 _pendingScrollFragment = fragment;
139 return;
140 }
141
142 var ctx = new MarkdownPreviewRenderContext(Payload?.SourcePath, ws);
143 var target = ctx.ResolveNavigateTarget(path);
144 if (string.IsNullOrWhiteSpace(target))
145 return;
146
147 if (!string.IsNullOrWhiteSpace(fragment))
148 _pendingScrollFragment = fragment;
149
150 if (!WorkspaceMarkdownPreviewOpener.TryOpenRepoDocument(
151 ws,
152 target,
153 (title, content, source) => SetContent(title, content, source),
154 out _))
155 return;
156 }
157
158 internal int? ConsumePendingScrollLine()
159 {
160 var line = _pendingScrollLine;
161 _pendingScrollLine = null;
162 return line;
163 }
164
165 internal string? ConsumePendingScrollFragment()
166 {
167 var fragment = _pendingScrollFragment;
168 _pendingScrollFragment = null;
169 return fragment;
170 }
171
172 internal void TryOpenCodeAnchor(string codeAnchorUrl)
173 {
174 if (_editorVm is null)
175 return;
176
177 var inner = Uri.UnescapeDataString(
178 codeAnchorUrl[MarkdownCodeAnchorPreviewExpander.UriScheme.Length..]);
179 if (!BracketCodeReferenceParser.TryParse(inner, out var reference, out _))
180 return;
181
182 var ws = TryGetWorkspaceRoot();
183 if (!BracketCodeReferenceParser.TryToAttachmentAnchor(
184 reference,
185 _editorVm.CurrentFilePath,
186 ws,
187 _editorVm.Workspace.SolutionPath,
188 indexDirectoryRelative: null,
189 out var anchor,
190 out _))
191 {
192 return;
193 }
194
195 var absolute = ResolveAnchorAbsolutePath(anchor.File, ws);
196 if (string.IsNullOrWhiteSpace(absolute))
197 return;
198
199 var line = anchor.LineStart is > 0 ? anchor.LineStart.Value : 1;
200 var endLine = anchor.LineEnd is > 0 ? anchor.LineEnd.Value : line;
201 _editorVm.IdeMcp.GoToPosition(absolute, line, 1, endLine, null);
202 }
203
204 internal void TryOpenForgeArtifact(string forgeArtifactUrl)
205 {
206 var inner = Uri.UnescapeDataString(
207 forgeArtifactUrl[MarkdownCodeAnchorPreviewExpander.ForgeUriScheme.Length..]);
208 if (!BracketForgeReferenceParser.TryParse(inner, out var artifact, out _))
209 return;
210
211 var ws = TryGetWorkspaceRoot();
212 var (baseUrl, _) = ForgeLensWorkspaceConfig.TryResolve(ws);
213 if (string.IsNullOrWhiteSpace(baseUrl))
214 baseUrl = "http://127.0.0.1:8770";
215
216 var viewUrl = ForgeLensOpenService.BuildViewUrl(baseUrl, artifact);
217 TryOpenExternalUrl(viewUrl);
218
219 if (string.IsNullOrWhiteSpace(artifact.CodeBracket) || _editorVm is null)
220 return;
221
222 if (!BracketCodeReferenceParser.TryParse(artifact.CodeBracket, out var reference, out _))
223 return;
224
225 if (!BracketCodeReferenceParser.TryToAttachmentAnchor(
226 reference,
227 _editorVm.CurrentFilePath,
228 ws,
229 _editorVm.Workspace.SolutionPath,
230 indexDirectoryRelative: null,
231 out var anchor,
232 out _))
233 {
234 return;
235 }
236
237 var absolute = ResolveAnchorAbsolutePath(anchor.File, ws);
238 if (string.IsNullOrWhiteSpace(absolute))
239 return;
240
241 var line = anchor.LineStart is > 0 ? anchor.LineStart.Value : 1;
242 var endLine = anchor.LineEnd is > 0 ? anchor.LineEnd.Value : line;
243 _editorVm.IdeMcp.GoToPosition(absolute, line, 1, endLine, null);
244 }
245
246 internal void TryOpenExternalUrl(string url)
247 {
248 try
249 {
250 Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
251 }
252 catch
253 {
254 // ignore — preview must not crash on bad URL
255 }
256 }
257
258 private static string? ResolveAnchorAbsolutePath(string? relativeFile, string? workspaceRoot)
259 {
260 if (string.IsNullOrWhiteSpace(relativeFile))
261 return null;
262
263 var normalized = relativeFile.Replace('\\', '/').Trim();
264 if (Path.IsPathRooted(normalized))
265 return normalized;
266
267 if (string.IsNullOrWhiteSpace(workspaceRoot))
268 return null;
269
270 var candidate = Path.GetFullPath(Path.Combine(workspaceRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
271 return File.Exists(candidate) ? candidate : candidate;
272 }
273
274 internal string? TryGetWorkspaceRoot()
275 {
276 if (_editorVm is null)
277 return null;
278
279 return WorkspaceDirectoryFromSolutionPath.Resolve(_editorVm.Workspace.SolutionPath ?? "");
280 }
281
282 public void RefreshFromEditor()
283 {
284 if (_editorVm is null)
285 {
286 SetEmptyState("Markdown Preview", "Preview is not attached to an editor.");
287 return;
288 }
289
290 if (!_editorVm.IsMarkdownFile)
291 {
292 SetEmptyState("Markdown Preview", "Open a Markdown document in the editor to preview it here.");
293 return;
294 }
295
296 var sourcePath = _editorVm.CurrentFilePath;
297 var title = string.IsNullOrWhiteSpace(sourcePath) ? "Markdown Preview" : sourcePath!;
298 var source = new MarkdownPreviewSource(title, _editorVm.EditorText ?? "", sourcePath);
299 QueueRefresh(source, BuildEditorSettingsSnapshot(_editorVm), emptyMessage: "Markdown document is empty.");
300 }
301
302 protected void SetEmptyState(string title, string message)
303 {
304 _refreshCts?.Cancel();
305 Title = title;
306 StatusText = message;
307 ErrorMessage = "";
308 Payload = null;
309 IsBusy = false;
310 }
311
312 private void QueueRefresh(MarkdownPreviewSource source, CascadeIdeSettings settings, string emptyMessage)
313 {
314 _refreshCts?.Cancel();
315 _refreshCts?.Dispose();
316 _refreshCts = new CancellationTokenSource();
317 var token = _refreshCts.Token;
318
319 Title = source.Title;
320 ErrorMessage = "";
321 IsBusy = true;
322 StatusText = string.IsNullOrWhiteSpace(source.Markdown) ? emptyMessage : "Refreshing preview…";
323
324 _ = BuildAndApplyAsync(source, settings, emptyMessage, token);
325 }
326
327 private async Task BuildAndApplyAsync(
328 MarkdownPreviewSource source,
329 CascadeIdeSettings settings,
330 string emptyMessage,
331 CancellationToken token)
332 {
333 try
334 {
335 var payload = await _payloadBuilder.BuildAsync(source, settings, token).ConfigureAwait(false);
336 await Dispatcher.UIThread.InvokeAsync(() =>
337 {
338 if (token.IsCancellationRequested)
339 return;
340
341 Title = payload.Title;
342 Payload = payload;
343 ErrorMessage = payload.ErrorMessage ?? "";
344 StatusText = string.IsNullOrWhiteSpace(payload.RenderMarkdown)
345 ? emptyMessage
346 : payload.Notices.Count > 0 ? string.Join(" | ", payload.Notices) : "";
347 IsBusy = false;
348 });
349 }
350 catch (OperationCanceledException)
351 {
352 // expected when text changes quickly
353 }
354 catch (Exception ex)
355 {
356 await Dispatcher.UIThread.InvokeAsync(() =>
357 {
358 if (token.IsCancellationRequested)
359 return;
360
361 Title = source.Title;
362 Payload = null;
363 ErrorMessage = ex.Message;
364 StatusText = "Markdown preview failed to refresh.";
365 IsBusy = false;
366 });
367 }
368 }
369
370 private static CascadeIdeSettings BuildEditorSettingsSnapshot(MainWindowViewModel vm)
371 {
372 return new CascadeIdeSettings
373 {
374 Markdown = new MarkdownSettings
375 {
376 Diagrams = new MarkdownDiagramSettings
377 {
378 Kroki = vm.MarkdownKrokiEnabled,
379 KrokiUrl = string.IsNullOrWhiteSpace(vm.MarkdownKrokiBaseUrl)
380 ? "https://kroki.io"
381 : vm.MarkdownKrokiBaseUrl.Trim()
382 }
383 }
384 };
385 }
386
387 public void Dispose()
388 {
389 DetachFromEditor();
390 GC.SuppressFinalize(this);
391 }
392}
393
View only · write via MCP/CIDE