Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs
Mika Kuns 31420574db feat(ui): show status messages and real diff-stats in DiffModal
- Count additions/deletions per file as lines are parsed.
- Surface load failures and empty-diff states via StatusMessage.
- Pass the worktree base commit so diffs render against the branch
  base, not just the working-tree HEAD.
2026-04-22 11:03:37 +02:00

191 lines
6.1 KiB
C#

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<DiffLineViewModel> 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<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public ObservableCollection<DiffFileViewModel> 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: @@ -<old>,<count> +<new>,<count> @@
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;
}
}
}