Forge
csharpdeeb25a2
1using System.Collections.ObjectModel;
2using System.Text;
3using Avalonia.Threading;
4using CascadeIDE.Features.Os.DataAcquisition;
5using CascadeIDE.Models;
6using CascadeIDE.ViewModels;
7using CommunityToolkit.Mvvm.ComponentModel;
8using CommunityToolkit.Mvvm.Input;
9
10namespace CascadeIDE.Features.Git;
11
12/// <summary>
13/// Вкладка «Git» нижней панели: статус, diff, submodule, коммит/push.
14/// </summary>
15public partial class GitPanelViewModel : ViewModelBase
16{
17 private readonly Services.IGitCommandRunner _gitRunner;
18 private readonly Func<string> _getWorkspacePath;
19 private readonly Services.IIdeMcpActions _ideActions;
20 private readonly Action<string> _loadSolution;
21 private readonly Func<Task> _refreshGitSummaryAsync;
22 private readonly IOsShellLauncher _osShell;
23
24 public GitPanelViewModel(
25 Services.IGitCommandRunner gitRunner,
26 Func<string> getWorkspacePath,
27 Services.IIdeMcpActions ideActions,
28 Action<string> loadSolution,
29 Func<Task> refreshGitSummaryAsync,
30 IOsShellLauncher? osShell = null)
31 {
32 _gitRunner = gitRunner;
33 _getWorkspacePath = getWorkspacePath;
34 _ideActions = ideActions;
35 _loadSolution = loadSolution;
36 _refreshGitSummaryAsync = refreshGitSummaryAsync;
37 _osShell = osShell ?? OsShell.Default;
38 }
39
40 [ObservableProperty]
41 [NotifyCanExecuteChangedFor(nameof(CommitGitFromPanelCommand))]
42 [NotifyCanExecuteChangedFor(nameof(PushGitFromPanelCommand))]
43 private bool _isGitRepository;
44
45 [ObservableProperty]
46 private string _gitBranchLine = "";
47
48 [ObservableProperty]
49 private string _gitPanelStatusText = "";
50
51 [ObservableProperty]
52 private string _gitCommitMessage = "";
53
54 [ObservableProperty]
55 private string _gitDiffText = "";
56
57 [ObservableProperty]
58 private string _gitSubmoduleStatusText = "";
59
60 [ObservableProperty]
61 private string _gitRepositoryContextText = "";
62
63 [ObservableProperty]
64 private string _gitSubmoduleCommandOutput = "";
65
66 [ObservableProperty]
67 private GitStatusRow? _selectedGitStatusRow;
68
69 public ObservableCollection<GitStatusRow> GitStatusRows { get; } = [];
70
71 partial void OnSelectedGitStatusRowChanged(GitStatusRow? value)
72 {
73 _ = LoadGitDiffForSelectionAsync();
74 StageGitSelectionCommand.NotifyCanExecuteChanged();
75 UnstageGitSelectionCommand.NotifyCanExecuteChanged();
76 OpenSubmoduleFolderCommand.NotifyCanExecuteChanged();
77 OpenSubmoduleSolutionInIdeCommand.NotifyCanExecuteChanged();
78 }
79
80 partial void OnGitCommitMessageChanged(string value)
81 {
82 CommitGitFromPanelCommand.NotifyCanExecuteChanged();
83 CommitGitStagedOnlyCommand.NotifyCanExecuteChanged();
84 }
85
86 partial void OnIsGitRepositoryChanged(bool value)
87 {
88 CommitGitFromPanelCommand.NotifyCanExecuteChanged();
89 PushGitFromPanelCommand.NotifyCanExecuteChanged();
90 CommitGitStagedOnlyCommand.NotifyCanExecuteChanged();
91 StageGitSelectionCommand.NotifyCanExecuteChanged();
92 UnstageGitSelectionCommand.NotifyCanExecuteChanged();
93 OpenSubmoduleFolderCommand.NotifyCanExecuteChanged();
94 GitSubmoduleUpdateInitCommand.NotifyCanExecuteChanged();
95 GitSubmoduleSyncCommand.NotifyCanExecuteChanged();
96 OpenSubmoduleSolutionInIdeCommand.NotifyCanExecuteChanged();
97 }
98
99 [RelayCommand]
100 public async Task RefreshGitPanelAsync()
101 {
102 var ws = _getWorkspacePath();
103 if (string.IsNullOrWhiteSpace(ws) || !Directory.Exists(ws))
104 {
105 await UiScheduler.Default.InvokeAsync(() =>
106 {
107 GitBranchLine = "";
108 GitStatusRows.Clear();
109 GitSubmoduleStatusText = "";
110 GitDiffText = "";
111 GitRepositoryContextText = "";
112 GitPanelStatusText = "Откройте решение (.sln / .slnx).";
113 IsGitRepository = false;
114 });
115 return;
116 }
117
118 var inside = await RunGitCommandAsync(["rev-parse", "--is-inside-work-tree"]).ConfigureAwait(false);
119 var inRepo = inside.Success && string.Equals(inside.Output.Trim(), "true", StringComparison.OrdinalIgnoreCase);
120 if (!inRepo)
121 {
122 await UiScheduler.Default.InvokeAsync(() =>
123 {
124 IsGitRepository = false;
125 GitBranchLine = "";
126 GitStatusRows.Clear();
127 GitSubmoduleStatusText = "";
128 GitDiffText = "";
129 GitRepositoryContextText = "";
130 GitPanelStatusText = "Каталог решения не в git-репозитории.";
131 });
132 return;
133 }
134
135 var status = await RunGitCommandAsync(["status", "--short", "--branch"]).ConfigureAwait(false);
136 var submodule = await RunGitCommandAsync(["submodule", "status"]).ConfigureAwait(false);
137 var subPaths = await LoadSubmodulePathsAsync(ws).ConfigureAwait(false);
138 var toplevel = await RunGitCommandAsync(["rev-parse", "--show-toplevel"]).ConfigureAwait(false);
139
140 await UiScheduler.Default.InvokeAsync(() =>
141 {
142 IsGitRepository = true;
143 GitPanelStatusText = status.Success ? "" : status.Output;
144 GitBranchLine = ExtractGitBranchLine(status.Output);
145 FillGitStatusRows(status.Output, subPaths);
146 GitSubmoduleStatusText = submodule.Success
147 ? submodule.Output.Trim()
148 : $"(submodule: {submodule.Output.Trim()})";
149 GitRepositoryContextText = BuildGitRepositoryContext(ws, toplevel.Success ? toplevel.Output : "");
150 });
151
152 await LoadGitDiffForSelectionAsync().ConfigureAwait(false);
153 }
154
155 /// <summary>Обновить флаг репозитория при смене решения (без полного refresh панели).</summary>
156 public async Task RefreshRepositoryFlagAsync()
157 {
158 var ws = _getWorkspacePath();
159 if (string.IsNullOrWhiteSpace(ws) || !Directory.Exists(ws))
160 {
161 await UiScheduler.Default.InvokeAsync(() => IsGitRepository = false);
162 return;
163 }
164
165 var inside = await RunGitCommandAsync(["rev-parse", "--is-inside-work-tree"]).ConfigureAwait(false);
166 var ok = inside.Success && string.Equals(inside.Output.Trim(), "true", StringComparison.OrdinalIgnoreCase);
167 await UiScheduler.Default.InvokeAsync(() => IsGitRepository = ok);
168 }
169
170 private void FillGitStatusRows(string output, HashSet<string> submodulePaths)
171 {
172 GitStatusRows.Clear();
173 foreach (var line in (output ?? "").Replace("\r\n", "\n").Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
174 {
175 if (line.StartsWith("##", StringComparison.Ordinal))
176 continue;
177 var rel = ParseGitStatusPath(line);
178 var path = rel ?? "";
179 var norm = path.Replace('\\', '/').TrimEnd('/');
180 var isUntracked = line.StartsWith("??", StringComparison.Ordinal);
181 var hasStaged = !isUntracked && line.Length >= 1 && line[0] is not (' ' or '?');
182 var isSub = !string.IsNullOrEmpty(norm) && submodulePaths.Contains(norm);
183 GitStatusRows.Add(new GitStatusRow
184 {
185 RawLine = line,
186 RelativePath = path,
187 IsUntracked = isUntracked,
188 HasStagedChanges = hasStaged,
189 IsSubmodulePath = isSub
190 });
191 }
192 }
193
194 private async Task<HashSet<string>> LoadSubmodulePathsAsync(string workspace)
195 {
196 var result = await RunGitCommandAsync(["config", "-f", ".gitmodules", "--get-regexp", "path"]).ConfigureAwait(false);
197 if (!result.Success)
198 return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
199
200 var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
201 foreach (var line in (result.Output ?? "").Replace("\r\n", "\n").Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
202 {
203 var tab = line.IndexOf('\t');
204 if (tab < 0)
205 continue;
206 var p = line[(tab + 1)..].Trim().Replace('\\', '/').TrimEnd('/');
207 if (!string.IsNullOrEmpty(p))
208 set.Add(p);
209 }
210
211 return set;
212 }
213
214 private static string ExtractGitBranchLine(string output)
215 {
216 var first = (output ?? "").Replace("\r\n", "\n").Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
217 return first is { Length: >= 3 } && first.StartsWith("## ", StringComparison.Ordinal) ? first[3..].Trim() : "";
218 }
219
220 private static string? ParseGitStatusPath(string line)
221 {
222 if (string.IsNullOrWhiteSpace(line))
223 return null;
224 if (line.StartsWith("##", StringComparison.Ordinal))
225 return null;
226 if (line.StartsWith("??", StringComparison.Ordinal))
227 return line.Length > 2 ? line[2..].Trim() : null;
228 if (line.Length < 4)
229 return null;
230 var trimmed = line[3..].Trim();
231 if (trimmed.Contains(" -> ", StringComparison.Ordinal))
232 {
233 var parts = trimmed.Split([" -> "], StringSplitOptions.None);
234 return parts.Length == 2 ? parts[1].Trim() : parts[0].Trim();
235 }
236
237 return trimmed;
238 }
239
240 private static string BuildGitRepositoryContext(string workspaceDir, string toplevelRaw)
241 {
242 if (string.IsNullOrWhiteSpace(workspaceDir))
243 return "";
244 try
245 {
246 var wsFull = CanonicalFilePath.Normalize(workspaceDir);
247 var topTrim = (toplevelRaw ?? "").Trim().Replace('/', Path.DirectorySeparatorChar);
248 if (string.IsNullOrEmpty(topTrim))
249 return $"Каталог решения: {wsFull}";
250 var topFull = CanonicalFilePath.Normalize(topTrim);
251 var same = string.Equals(wsFull, topFull, StringComparison.OrdinalIgnoreCase);
252 return same
253 ? $"Корень git: {topFull}"
254 : $"Корень git: {topFull} · каталог решения: {wsFull}";
255 }
256 catch
257 {
258 return (toplevelRaw ?? "").Trim();
259 }
260 }
261
262 private static string? FindSolutionFileInDirectory(string submoduleRoot)
263 {
264 if (string.IsNullOrWhiteSpace(submoduleRoot) || !Directory.Exists(submoduleRoot))
265 return null;
266
267 static string? PickFirst(IEnumerable<string> files)
268 {
269 var arr = files as string[] ?? files.ToArray();
270 return arr.Length == 0 ? null : arr.OrderBy(static f => f, StringComparer.OrdinalIgnoreCase).First();
271 }
272
273 var topSlnx = Directory.GetFiles(submoduleRoot, "*.slnx", SearchOption.TopDirectoryOnly);
274 var topSln = Directory.GetFiles(submoduleRoot, "*.sln", SearchOption.TopDirectoryOnly);
275 var p = PickFirst(topSlnx) ?? PickFirst(topSln);
276 if (p is not null)
277 return p;
278
279 foreach (var sub in Directory.GetDirectories(submoduleRoot))
280 {
281 var innerSlnx = Directory.GetFiles(sub, "*.slnx", SearchOption.TopDirectoryOnly);
282 var innerSln = Directory.GetFiles(sub, "*.sln", SearchOption.TopDirectoryOnly);
283 p = PickFirst(innerSlnx) ?? PickFirst(innerSln);
284 if (p is not null)
285 return p;
286 }
287
288 return null;
289 }
290
291 private async Task LoadGitDiffForSelectionAsync()
292 {
293 if (SelectedGitStatusRow is not { HasPath: true } row || string.IsNullOrWhiteSpace(row.RelativePath))
294 {
295 await UiScheduler.Default.InvokeAsync(() => GitDiffText = "");
296 return;
297 }
298
299 var unstaged = await RunGitCommandAsync(["diff", "--", row.RelativePath]).ConfigureAwait(false);
300 var staged = await RunGitCommandAsync(["diff", "--staged", "--", row.RelativePath]).ConfigureAwait(false);
301 var sb = new StringBuilder();
302 if (staged.Success && !string.IsNullOrWhiteSpace(staged.Output))
303 {
304 sb.AppendLine("--- staged ---");
305 sb.AppendLine(staged.Output);
306 sb.AppendLine();
307 }
308
309 if (unstaged.Success && !string.IsNullOrWhiteSpace(unstaged.Output))
310 {
311 sb.AppendLine("--- unstaged ---");
312 sb.AppendLine(unstaged.Output);
313 }
314
315 if (sb.Length == 0)
316 sb.AppendLine("(нет diff для выбранного пути)");
317
318 await UiScheduler.Default.InvokeAsync(() => GitDiffText = sb.ToString());
319 }
320
321 [RelayCommand(CanExecute = nameof(CanCommitGitFromPanel))]
322 private async Task CommitGitFromPanelAsync()
323 {
324 var msg = GitCommitMessage.Trim();
325 if (string.IsNullOrWhiteSpace(msg) || !IsGitRepository)
326 return;
327
328 GitPanelStatusText = "Коммит…";
329 var json = await _ideActions.GitCommitAsync(msg, null).ConfigureAwait(false);
330 await UiScheduler.Default.InvokeAsync(() =>
331 {
332 GitPanelStatusText = json;
333 GitCommitMessage = "";
334 });
335 await RefreshGitPanelAsync().ConfigureAwait(false);
336 await _refreshGitSummaryAsync().ConfigureAwait(false);
337 }
338
339 private bool CanCommitGitFromPanel() => IsGitRepository && !string.IsNullOrWhiteSpace(GitCommitMessage);
340
341 [RelayCommand(CanExecute = nameof(CanPushGitFromPanel))]
342 private async Task PushGitFromPanelAsync()
343 {
344 if (!IsGitRepository)
345 return;
346
347 GitPanelStatusText = "Push…";
348 var json = await _ideActions.GitPushAsync(null, null).ConfigureAwait(false);
349 await UiScheduler.Default.InvokeAsync(() => GitPanelStatusText = json);
350 await RefreshGitPanelAsync().ConfigureAwait(false);
351 await _refreshGitSummaryAsync().ConfigureAwait(false);
352 }
353
354 private bool CanPushGitFromPanel() => IsGitRepository;
355
356 [RelayCommand(CanExecute = nameof(CanStageGitSelection))]
357 private async Task StageGitSelectionAsync()
358 {
359 if (SelectedGitStatusRow is not { HasPath: true, RelativePath: var rel })
360 return;
361
362 GitPanelStatusText = "";
363 var r = await RunGitCommandAsync(["add", "--", rel]).ConfigureAwait(false);
364 await UiScheduler.Default.InvokeAsync(() => GitPanelStatusText = r.Success ? "" : r.Output);
365 await RefreshGitPanelAsync().ConfigureAwait(false);
366 await _refreshGitSummaryAsync().ConfigureAwait(false);
367 }
368
369 private bool CanStageGitSelection() => IsGitRepository && SelectedGitStatusRow is { HasPath: true };
370
371 [RelayCommand(CanExecute = nameof(CanUnstageGitSelection))]
372 private async Task UnstageGitSelectionAsync()
373 {
374 if (SelectedGitStatusRow is not { HasPath: true, RelativePath: var rel })
375 return;
376
377 GitPanelStatusText = "";
378 var r = await RunGitCommandAsync(["restore", "--staged", "--", rel]).ConfigureAwait(false);
379 await UiScheduler.Default.InvokeAsync(() => GitPanelStatusText = r.Success ? "" : r.Output);
380 await RefreshGitPanelAsync().ConfigureAwait(false);
381 await _refreshGitSummaryAsync().ConfigureAwait(false);
382 }
383
384 private bool CanUnstageGitSelection() =>
385 IsGitRepository && SelectedGitStatusRow is { HasPath: true, HasStagedChanges: true };
386
387 [RelayCommand(CanExecute = nameof(CanOpenSubmoduleFolder))]
388 private void OpenSubmoduleFolder()
389 {
390 if (SelectedGitStatusRow is not { IsSubmodulePath: true, RelativePath: var rel })
391 return;
392
393 var ws = _getWorkspacePath();
394 if (string.IsNullOrWhiteSpace(ws))
395 return;
396
397 var full = CanonicalFilePath.Normalize(Path.Combine(ws, rel.Replace('/', Path.DirectorySeparatorChar)));
398 if (!Directory.Exists(full))
399 return;
400
401 _osShell.TryOpenDirectory(full, e => GitPanelStatusText = e);
402 }
403
404 private bool CanOpenSubmoduleFolder()
405 {
406 if (!IsGitRepository || SelectedGitStatusRow is not { IsSubmodulePath: true, RelativePath: var rel })
407 return false;
408
409 var ws = _getWorkspacePath();
410 if (string.IsNullOrWhiteSpace(ws))
411 return false;
412
413 var full = CanonicalFilePath.Normalize(Path.Combine(ws, rel.Replace('/', Path.DirectorySeparatorChar)));
414 return Directory.Exists(full);
415 }
416
417 [RelayCommand(CanExecute = nameof(CanOpenSubmoduleSolutionInIde))]
418 private void OpenSubmoduleSolutionInIde()
419 {
420 if (SelectedGitStatusRow is not { IsSubmodulePath: true, RelativePath: var rel })
421 return;
422
423 var ws = _getWorkspacePath();
424 if (string.IsNullOrWhiteSpace(ws))
425 return;
426
427 var dir = CanonicalFilePath.Normalize(Path.Combine(ws, rel.Replace('/', Path.DirectorySeparatorChar)));
428 if (!Directory.Exists(dir))
429 return;
430
431 var sln = FindSolutionFileInDirectory(dir);
432 if (string.IsNullOrEmpty(sln))
433 {
434 GitPanelStatusText =
435 "В каталоге submodule не найден .sln / .slnx (корень и подпапки первого уровня).";
436 return;
437 }
438
439 _loadSolution(sln);
440 }
441
442 private bool CanOpenSubmoduleSolutionInIde()
443 {
444 if (!IsGitRepository || SelectedGitStatusRow is not { IsSubmodulePath: true, RelativePath: var rel })
445 return false;
446
447 var ws = _getWorkspacePath();
448 if (string.IsNullOrWhiteSpace(ws))
449 return false;
450
451 var full = CanonicalFilePath.Normalize(Path.Combine(ws, rel.Replace('/', Path.DirectorySeparatorChar)));
452 return Directory.Exists(full) && FindSolutionFileInDirectory(full) is not null;
453 }
454
455 [RelayCommand(CanExecute = nameof(CanRunGitSubmoduleCommands))]
456 private async Task GitSubmoduleUpdateInitAsync()
457 {
458 GitSubmoduleCommandOutput = "git submodule update --init --recursive …\n";
459 var r = await RunGitCommandAsync(["submodule", "update", "--init", "--recursive"]).ConfigureAwait(false);
460 await UiScheduler.Default.InvokeAsync(() =>
461 {
462 GitSubmoduleCommandOutput = TruncateOutput(r.Output, 8000);
463 GitPanelStatusText = r.Success ? "" : "Submodule update завершился с ошибкой (см. вывод ниже).";
464 });
465 await RefreshGitPanelAsync().ConfigureAwait(false);
466 await _refreshGitSummaryAsync().ConfigureAwait(false);
467 }
468
469 [RelayCommand(CanExecute = nameof(CanRunGitSubmoduleCommands))]
470 private async Task GitSubmoduleSyncAsync()
471 {
472 GitSubmoduleCommandOutput = "git submodule sync --recursive …\n";
473 var r = await RunGitCommandAsync(["submodule", "sync", "--recursive"]).ConfigureAwait(false);
474 await UiScheduler.Default.InvokeAsync(() =>
475 {
476 GitSubmoduleCommandOutput = TruncateOutput(r.Output, 8000);
477 if (!r.Success)
478 GitPanelStatusText = "Submodule sync завершился с ошибкой (см. вывод ниже).";
479 });
480 await RefreshGitPanelAsync().ConfigureAwait(false);
481 await _refreshGitSummaryAsync().ConfigureAwait(false);
482 }
483
484 private bool CanRunGitSubmoduleCommands() => IsGitRepository;
485
486 [RelayCommand(CanExecute = nameof(CanCommitGitStagedOnly))]
487 private async Task CommitGitStagedOnlyAsync()
488 {
489 var msg = GitCommitMessage.Trim();
490 if (string.IsNullOrWhiteSpace(msg) || !IsGitRepository)
491 return;
492
493 GitPanelStatusText = "Коммит (только staged)…";
494 var result = await RunGitCommandAsync(["commit", "-m", msg]).ConfigureAwait(false);
495 await UiScheduler.Default.InvokeAsync(() =>
496 {
497 GitPanelStatusText = result.Success ? "" : result.Output;
498 if (result.Success)
499 GitCommitMessage = "";
500 });
501 await RefreshGitPanelAsync().ConfigureAwait(false);
502 await _refreshGitSummaryAsync().ConfigureAwait(false);
503 }
504
505 private bool CanCommitGitStagedOnly() => IsGitRepository && !string.IsNullOrWhiteSpace(GitCommitMessage);
506
507 private async Task<(bool Success, int ExitCode, string Output)> RunGitCommandAsync(IReadOnlyList<string> args)
508 {
509 var workspace = _getWorkspacePath();
510 if (string.IsNullOrWhiteSpace(workspace) || !Directory.Exists(workspace))
511 return (false, -1, "Workspace path is not available.");
512 return await _gitRunner.RunAsync(args, workspace).ConfigureAwait(false);
513 }
514
515 private static string TruncateOutput(string? text, int maxChars)
516 {
517 if (string.IsNullOrEmpty(text))
518 return "";
519 return text.Length > maxChars ? text[..maxChars] + "\n... (output truncated)" : text;
520 }
521}
522
View only · write via MCP/CIDE