using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data.Git; namespace ClaudeDo.Ui.ViewModels.Modals; public enum DiffLineKind { Add, Del, Ctx } public sealed class DiffLineViewModel { public required DiffLineKind Kind { get; init; } public int? OldNo { get; init; } public int? NewNo { get; init; } public required string Text { get; init; } public string ClassName => Kind switch { DiffLineKind.Add => "add", DiffLineKind.Del => "del", _ => "ctx", }; public string Sign => Kind switch { DiffLineKind.Add => "+", DiffLineKind.Del => "-", _ => " ", }; } public sealed class DiffFileViewModel { public required string Path { get; init; } public int Additions { get; set; } public int Deletions { get; set; } public ObservableCollection Lines { get; } = new(); } public sealed partial class DiffModalViewModel : ViewModelBase { private readonly GitService _git; public required string WorktreePath { get; init; } public string? BaseRef { get; init; } public string? TaskId { get; init; } public string TaskTitle { get; init; } = ""; public Func? ShowMergeModal { get; set; } public Func? ResolveMergeVm { get; set; } public ObservableCollection Files { get; } = new(); [ObservableProperty] private DiffFileViewModel? _selectedFile; [ObservableProperty] private string? _statusMessage; // Injected action to close the owning Window public Action? CloseAction { get; set; } public DiffModalViewModel(GitService git) { _git = git; } [RelayCommand] private void Close() => CloseAction?.Invoke(); private bool CanMerge() => !string.IsNullOrEmpty(TaskId) && ShowMergeModal is not null && ResolveMergeVm is not null; [RelayCommand(CanExecute = nameof(CanMerge))] private async Task MergeAsync() { if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return; var vm = ResolveMergeVm(); await vm.InitializeAsync(TaskId, TaskTitle); await ShowMergeModal(vm); } public async Task LoadAsync(CancellationToken ct = default) { Files.Clear(); StatusMessage = null; string raw; try { raw = BaseRef is not null ? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct) : await _git.GetDiffAsync(WorktreePath, ct); } catch (Exception ex) { StatusMessage = $"Failed to load diff: {ex.Message}"; return; } if (string.IsNullOrWhiteSpace(raw)) { StatusMessage = "No changes to show."; return; } // Parse unified diff — state machine over lines DiffFileViewModel? current = null; int oldLine = 0, newLine = 0; foreach (var line in raw.Split('\n')) { if (line.StartsWith("diff --git ", StringComparison.Ordinal)) { // e.g. "diff --git a/src/Foo.cs b/src/Foo.cs" var parts = line.Split(' '); var path = parts.Length >= 4 ? parts[3][2..] : line; current = new DiffFileViewModel { Path = path }; Files.Add(current); oldLine = 0; newLine = 0; continue; } if (current == null) continue; if (line.StartsWith("@@ ", StringComparison.Ordinal)) { // e.g. "@@ -10,7 +10,9 @@" ParseHunkHeader(line, out oldLine, out newLine); continue; } // Skip diff metadata lines if (line.StartsWith("--- ", StringComparison.Ordinal) || line.StartsWith("+++ ", StringComparison.Ordinal) || line.StartsWith("index ", StringComparison.Ordinal) || line.StartsWith("new file", StringComparison.Ordinal) || line.StartsWith("deleted file", StringComparison.Ordinal) || line.StartsWith("Binary ", StringComparison.Ordinal)) continue; if (line.StartsWith('+')) { current.Lines.Add(new DiffLineViewModel { Kind = DiffLineKind.Add, NewNo = newLine++, Text = line.Length > 1 ? line[1..] : "", }); current.Additions++; } else if (line.StartsWith('-')) { current.Lines.Add(new DiffLineViewModel { Kind = DiffLineKind.Del, OldNo = oldLine++, Text = line.Length > 1 ? line[1..] : "", }); current.Deletions++; } else if (line.StartsWith(' ')) { current.Lines.Add(new DiffLineViewModel { Kind = DiffLineKind.Ctx, OldNo = oldLine++, NewNo = newLine++, Text = line.Length > 1 ? line[1..] : "", }); } } SelectedFile = Files.Count > 0 ? Files[0] : null; if (Files.Count == 0) StatusMessage = "No changes to show."; } private static void ParseHunkHeader(string header, out int oldStart, out int newStart) { oldStart = 1; newStart = 1; // Format: @@ -, +, @@ var at = header.IndexOf("@@", 3, StringComparison.Ordinal); var inner = at > 0 ? header[3..at].Trim() : header; var segs = inner.Split(' '); foreach (var seg in segs) { if (seg.StartsWith('-') && int.TryParse(seg[1..].Split(',')[0], out var o)) oldStart = o; else if (seg.StartsWith('+') && int.TryParse(seg[1..].Split(',')[0], out var n)) newStart = n; } } }