Forge
csharpdeeb25a2
1#nullable enable
2
3using System.Text.Json;
4using CascadeIDE.Features.Workspace.Application;
5using CascadeIDE.Models.Intercom;
6using CascadeIDE.Services;
7using CascadeIDE.Services.Intercom;
8using CascadeIDE.ViewModels;
9using CommunityToolkit.Mvvm.ComponentModel;
10
11namespace CascadeIDE.Features.Chat;
12
13public partial class ChatPanelViewModel
14{
15 private Func<IReadOnlyList<EditorDiagnosticStrip>>? _getDiagnosticStripsForCurrentFile;
16 private Action? _focusIntercomComposer;
17
18 private readonly Dictionary<string, AttachmentAnchor> _pendingAttachDrafts = new(StringComparer.OrdinalIgnoreCase);
19
20 [ObservableProperty]
21 private string _composerAttachHint = "";
22
23 [ObservableProperty]
24 private int _chatComposerCaretIndex;
25
26 public static bool IsComposerAttachSlash(string? slashPath) =>
27 !string.IsNullOrWhiteSpace(slashPath)
28 && slashPath.StartsWith("/attach", StringComparison.OrdinalIgnoreCase);
29
30 public void SetDiagnosticStripsAccessor(Func<IReadOnlyList<EditorDiagnosticStrip>>? getStripsForCurrentFile) =>
31 _getDiagnosticStripsForCurrentFile = getStripsForCurrentFile;
32
33 public void SetFocusIntercomComposerAction(Action? focusComposer) =>
34 _focusIntercomComposer = focusComposer;
35
36 public string AttachSelectionToComposer() =>
37 runAttachAffordance(ChatSlashIntercomHandlers.Ids.AttachSelection);
38
39 public string AttachScopeToComposer() =>
40 runAttachAffordance(ChatSlashIntercomHandlers.Ids.AttachScope);
41
42 public string AttachDiagnosticAtCaretToComposer()
43 {
44 var editor = BuildAttachEditorSnapshot();
45 var workspace = ResolveAttachWorkspaceRoot();
46 var solution = ResolveAttachSolutionPath();
47 var strips = _getDiagnosticStripsForCurrentFile?.Invoke() ?? Array.Empty<EditorDiagnosticStrip>();
48 if (!IntercomAttachmentResolveAtSend.TryResolveDiagnosticAtCaret(
49 editor,
50 strips,
51 workspace,
52 solution,
53 out var draft,
54 out var error))
55 {
56 return error;
57 }
58
59 return completeAttachAffordance(draft);
60 }
61
62 public string AttachProblemToComposer(ProblemListItem problem)
63 {
64 if (problem is null)
65 return "Нет выбранной диагностики.";
66
67 var workspace = ResolveAttachWorkspaceRoot();
68 var solution = ResolveAttachSolutionPath();
69 if (!IntercomAttachmentResolveAtSend.TryResolveFile(
70 problem.FilePath,
71 problem.Line,
72 problem.Line,
73 workspace,
74 solution,
75 out var draft,
76 out var error))
77 {
78 return error;
79 }
80
81 draft = draft with
82 {
83 DisplayLabel = $"{problem.FileName}:{problem.Line} {problem.Id}",
84 };
85 return completeAttachAffordance(draft);
86 }
87
88 public string AttachDragKindToComposer(string kind) => kind switch
89 {
90 IntercomAttachDragFormats.KindSelection => AttachSelectionToComposer(),
91 IntercomAttachDragFormats.KindScope => AttachScopeToComposer(),
92 _ => $"Неизвестный attach: {kind}",
93 };
94
95 public string ApplyAttachDropPayload(string payload)
96 {
97 if (payload.StartsWith(IntercomAttachDragFormats.TextPrefix, StringComparison.Ordinal))
98 payload = payload[IntercomAttachDragFormats.TextPrefix.Length..];
99
100 if (payload.StartsWith('{'))
101 {
102 try
103 {
104 using var doc = JsonDocument.Parse(payload);
105 var root = doc.RootElement;
106 var kind = root.TryGetProperty("kind", out var kindEl) && kindEl.ValueKind == JsonValueKind.String
107 ? kindEl.GetString()
108 : null;
109 if (string.Equals(kind, IntercomAttachDragFormats.KindProblem, StringComparison.OrdinalIgnoreCase)
110 && root.TryGetProperty("filePath", out var pathEl)
111 && pathEl.ValueKind == JsonValueKind.String
112 && root.TryGetProperty("line", out var lineEl)
113 && lineEl.TryGetInt32(out var line))
114 {
115 var id = root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String
116 ? idEl.GetString() ?? ""
117 : "";
118 var severity = root.TryGetProperty("severity", out var sevEl) && sevEl.ValueKind == JsonValueKind.String
119 ? sevEl.GetString() ?? "error"
120 : "error";
121 var msg = root.TryGetProperty("message", out var msgEl) && msgEl.ValueKind == JsonValueKind.String
122 ? msgEl.GetString() ?? ""
123 : "";
124 return AttachProblemToComposer(new ProblemListItem(
125 pathEl.GetString()!,
126 line,
127 1,
128 severity,
129 id,
130 msg));
131 }
132
133 return AttachDragKindToComposer(kind ?? "");
134 }
135 catch
136 {
137 return AttachDragKindToComposer(payload);
138 }
139 }
140
141 return AttachDragKindToComposer(payload);
142 }
143
144 public ChatSlashIntercomResult TryExecuteAttachSlash(string handlerId, string? argsTail)
145 {
146 var editor = BuildAttachEditorSnapshot();
147 var workspace = ResolveAttachWorkspaceRoot();
148 var solution = ResolveAttachSolutionPath();
149
150 AttachmentAnchor draft;
151 string error;
152 switch (handlerId)
153 {
154 case ChatSlashIntercomHandlers.Ids.AttachSelection:
155 if (!IntercomAttachmentResolveAtSend.TryResolveSelection(editor, workspace, solution, out draft, out error))
156 return ChatSlashIntercomResult.Fail(error);
157 break;
158 case ChatSlashIntercomHandlers.Ids.AttachScope:
159 if (!IntercomAttachmentResolveAtSend.TryResolveScope(editor, workspace, solution, out draft, out error))
160 return ChatSlashIntercomResult.Fail(error);
161 break;
162 case ChatSlashIntercomHandlers.Ids.AttachFile:
163 if (!tryResolveAttachFileArgs(argsTail, workspace, solution, out draft, out error))
164 return ChatSlashIntercomResult.Fail(error);
165 break;
166 default:
167 return ChatSlashIntercomResult.Fail($"Неизвестный attach: {handlerId}");
168 }
169
170 return insertPendingAttach(draft);
171 }
172
173 private string runAttachAffordance(string handlerId)
174 {
175 var result = TryExecuteAttachSlash(handlerId, null);
176 if (!result.Success)
177 return result.Message;
178
179 return completeAttachAffordanceMessage(result.Message);
180 }
181
182 private string completeAttachAffordance(AttachmentAnchor draft)
183 {
184 var result = insertPendingAttach(draft);
185 return result.Success
186 ? completeAttachAffordanceMessage(result.Message)
187 : result.Message;
188 }
189
190 private string completeAttachAffordanceMessage(string? detail)
191 {
192 _focusIntercomComposer?.Invoke();
193 if (!string.IsNullOrWhiteSpace(detail))
194 ClarificationStatusText = detail;
195 return detail ?? "OK";
196 }
197
198 private ChatSlashIntercomResult insertPendingAttach(AttachmentAnchor draft)
199 {
200 var shortId = IntercomAttachmentMarkers.NewShortId();
201 _pendingAttachDrafts[shortId] = draft with { Id = shortId };
202
203 var marker = IntercomAttachmentMarkers.FormatMarker(shortId);
204 var caret = Math.Clamp(ChatComposerCaretIndex, 0, ChatInput.Length);
205 var prefix = caret > 0 && !char.IsWhiteSpace(ChatInput[caret - 1]) ? " " : "";
206 var suffix = caret < ChatInput.Length && !char.IsWhiteSpace(ChatInput[caret]) ? " " : "";
207 ChatInput = ChatInput.Insert(caret, prefix + marker + suffix);
208 ChatComposerCaretIndex = caret + prefix.Length + marker.Length + suffix.Length;
209 refreshComposerAttachHint();
210 RefreshComposerAutocomplete();
211
212 var label = draft.DisplayLabel ?? shortId;
213 return ChatSlashIntercomResult.Ok($"Прикреплено: {label}");
214 }
215
216 private void refreshComposerAttachHint()
217 {
218 if (_pendingAttachDrafts.Count == 0)
219 {
220 ComposerAttachHint = "";
221 return;
222 }
223
224 var labels = _pendingAttachDrafts.Values
225 .Select(a => a.DisplayLabel)
226 .Where(l => !string.IsNullOrWhiteSpace(l))
227 .Take(4)
228 .ToList();
229 var more = _pendingAttachDrafts.Count - labels.Count;
230 ComposerAttachHint = labels.Count == 0
231 ? $"Прикреплено: {_pendingAttachDrafts.Count}"
232 : more > 0
233 ? $"Прикреплено: {string.Join(", ", labels)} +{more}"
234 : $"Прикреплено: {string.Join(", ", labels)}";
235 }
236
237 /// <summary>Корень для resolve вложений: workspace VM, иначе каталог .sln.</summary>
238 private string ResolveAttachWorkspaceRoot()
239 {
240 var ws = _getWorkspaceRoot()?.Trim() ?? "";
241 if (ws.Length > 0)
242 return ws;
243 return WorkspaceDirectoryFromSolutionPath.Resolve(_getSolutionPath?.Invoke() ?? "");
244 }
245
246 /// <summary>Абсолютный путь решения: открытое в UI, иначе <c>session.meta.solution_path</c> (.slnx и др.).</summary>
247 public string? ResolveAttachSolutionPath() =>
248 IntercomAttachScope.ResolveSolutionPath(
249 ResolveAttachWorkspaceRoot(),
250 _getSolutionPath?.Invoke(),
251 _sessionSolutionPathRelative);
252
253 private string? ResolveAttachIndexDirectoryRelative() =>
254 CascadeIDE.Features.HybridIndex.Application.HybridIndexIndexDirectoryRelative.ResolveOrDefault(
255 _getCascadeSettings?.Invoke().HybridIndex.IndexDir);
256
257 private IntercomAttachmentResolveAtSend.EditorSnapshot BuildAttachEditorSnapshot() =>
258 new(
259 _getCurrentFilePath?.Invoke(),
260 _getEditorText?.Invoke(),
261 _getEditorSelectionStart?.Invoke(),
262 _getEditorSelectionLength?.Invoke(),
263 _getEditorCaretOffset?.Invoke());
264
265 private static bool tryResolveAttachFileArgs(
266 string? argsTail,
267 string? workspaceRoot,
268 string? solutionPath,
269 out AttachmentAnchor anchor,
270 out string error)
271 {
272 anchor = new AttachmentAnchor();
273 error = "";
274 var parts = (argsTail ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
275 if (parts.Length == 0)
276 {
277 error = "Укажи путь: /attach file <path> [start] [end].";
278 return false;
279 }
280
281 int? lineStart = null;
282 int? lineEnd = null;
283 if (parts.Length >= 2 && int.TryParse(parts[^2], out var s) && int.TryParse(parts[^1], out var e))
284 {
285 lineStart = s;
286 lineEnd = e;
287 parts = parts[..^2];
288 }
289 else if (parts.Length >= 2 && int.TryParse(parts[^1], out var single))
290 {
291 lineStart = single;
292 lineEnd = single;
293 parts = parts[..^1];
294 }
295
296 var path = string.Join(' ', parts);
297 return IntercomAttachmentResolveAtSend.TryResolveFile(path, lineStart, lineEnd, workspaceRoot, solutionPath, out anchor, out error);
298 }
299
300 public async Task RevealAttachmentFromFeedAsync(
301 AttachmentAnchor anchor,
302 bool select,
303 int? messageIndex = null,
304 CancellationToken cancellationToken = default)
305 {
306 anchor = resolveFeedAttachmentAnchor(anchor, messageIndex);
307 if (string.IsNullOrWhiteSpace(anchor.File))
308 {
309 await UiScheduler.Default.InvokeAsync(() =>
310 ClarificationStatusText = "Не удалось перейти: у вложения нет пути к файлу.");
311 return;
312 }
313
314 try
315 {
316 string result;
317 if (_revealIntercomAttachmentInIde is { } revealInIde)
318 {
319 result = await revealInIde(anchor, select, cancellationToken).ConfigureAwait(true);
320 }
321 else if (_executeIdeCommandForMafAgent is { } exec)
322 {
323 var anchorJson = JsonSerializer.SerializeToElement(anchor, ChatPanelJson);
324 var args = new Dictionary<string, JsonElement>
325 {
326 ["anchor_json"] = anchorJson,
327 ["select"] = JsonSerializer.SerializeToElement(select),
328 };
329 result = await exec(IdeCommands.IntercomRevealAttachment, args, cancellationToken).ConfigureAwait(true);
330 }
331 else
332 {
333 await UiScheduler.Default.InvokeAsync(() =>
334 ClarificationStatusText = "Не удалось перейти: IDE bridge недоступен.");
335 return;
336 }
337
338 await UiScheduler.Default.InvokeAsync(() =>
339 ClarificationStatusText = string.IsNullOrWhiteSpace(result) ? "OK" : result.Trim());
340 }
341 catch (Exception ex)
342 {
343 await UiScheduler.Default.InvokeAsync(() => ClarificationStatusText = ex.Message);
344 }
345 }
346
347 private AttachmentAnchor resolveFeedAttachmentAnchor(AttachmentAnchor anchor, int? messageIndex)
348 {
349 if (!string.IsNullOrWhiteSpace(anchor.File))
350 return anchor;
351
352 if (messageIndex is not >= 0 || messageIndex >= ChatMessages.Count)
353 return anchor;
354
355 var attachments = ChatMessages[messageIndex.Value].Attachments;
356 if (attachments is null || attachments.Count == 0)
357 return anchor;
358
359 if (!string.IsNullOrWhiteSpace(anchor.Id))
360 {
361 foreach (var candidate in attachments)
362 {
363 if (string.IsNullOrWhiteSpace(candidate.Id)
364 || !string.Equals(candidate.Id, anchor.Id, StringComparison.OrdinalIgnoreCase))
365 {
366 continue;
367 }
368
369 return candidate;
370 }
371 }
372
373 return attachments[0];
374 }
375}
376
View only · write via MCP/CIDE