refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff

This commit is contained in:
Mika Kuns
2026-06-26 16:11:49 +02:00
parent 4022bd7197
commit 167d2fec6a
28 changed files with 923 additions and 1120 deletions
@@ -0,0 +1,131 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals;
// Shared diff models used by UnifiedDiffParser, DiffLinesView and DiffViewerViewModel.
public enum DiffLineKind { Add, Del, Ctx, File }
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
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",
DiffLineKind.File => "file",
_ => "ctx",
};
public string Sign => Kind switch
{
DiffLineKind.Add => "+",
DiffLineKind.Del => "-",
_ => " ",
};
}
public sealed class DiffFileViewModel
{
public required string Path { get; set; }
public string? OldPath { get; set; }
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
public bool IsBinary { get; set; }
public int Additions { get; set; }
public int Deletions { get; set; }
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
/// Single-letter badge for the file's change kind (A/M/D/R).
public string StatusCode => Status switch
{
DiffFileStatus.Added => "A",
DiffFileStatus.Deleted => "D",
DiffFileStatus.Renamed => "R",
_ => "M",
};
public bool HasLines => Lines.Count > 0;
/// A text file that produced no diff hunks (e.g. a newly added empty file).
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
}
/// One row in the planning subtask list (left pane in Planning mode).
public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);
/// Folder/file node for the file-tree nav (left pane in Files mode). File leaves carry
/// their parsed <see cref="DiffFileViewModel"/> so selection swaps the right pane with no
/// further git calls.
public sealed partial class DiffTreeNodeViewModel : ViewModelBase
{
public required string Name { get; init; }
public bool IsDirectory { get; init; }
public string RelativePath { get; init; } = "";
public DiffFileViewModel? File { get; init; }
public ObservableCollection<DiffTreeNodeViewModel> Children { get; } = new();
[ObservableProperty] private bool _isExpanded = true;
public string? StatusCode => File?.StatusCode;
public bool ShowStats => File is { IsBinary: false };
public int Additions => File?.Additions ?? 0;
public int Deletions => File?.Deletions ?? 0;
}
/// Builds a folder-grouped tree from a flat list of parsed diff files.
public static class DiffTree
{
public static List<DiffTreeNodeViewModel> Build(IEnumerable<DiffFileViewModel> files)
{
var roots = new List<DiffTreeNodeViewModel>();
var dirs = new Dictionary<string, DiffTreeNodeViewModel>(StringComparer.Ordinal);
foreach (var file in files)
{
var path = file.Path.Replace('\\', '/');
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0) continue;
DiffTreeNodeViewModel? parent = null;
var accumulated = "";
for (var i = 0; i < segments.Length - 1; i++)
{
accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i];
if (!dirs.TryGetValue(accumulated, out var dir))
{
dir = new DiffTreeNodeViewModel { Name = segments[i], IsDirectory = true, RelativePath = accumulated };
dirs[accumulated] = dir;
if (parent is null) roots.Add(dir); else parent.Children.Add(dir);
}
parent = dir;
}
var leaf = new DiffTreeNodeViewModel
{
Name = segments[^1],
IsDirectory = false,
RelativePath = path,
File = file,
};
if (parent is null) roots.Add(leaf); else parent.Children.Add(leaf);
}
return roots;
}
public static DiffTreeNodeViewModel? FirstLeaf(IEnumerable<DiffTreeNodeViewModel> nodes)
{
foreach (var n in nodes)
{
if (!n.IsDirectory) return n;
var nested = FirstLeaf(n.Children);
if (nested is not null) return nested;
}
return null;
}
}