refactor(ui): share color-coded diff rendering between per-task and combined diff viewers
Extract the unified-diff parser into UnifiedDiffParser and the styled line renderer into a reusable DiffLinesView control. The combined (planning) diff now parses its unified-diff string and renders color-coded rows (green additions / red deletions, file headers) identical to the per-task viewer instead of dumping plain text into a TextBox. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ using ClaudeDo.Ui.Localization;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public enum DiffLineKind { Add, Del, Ctx }
|
||||
public enum DiffLineKind { Add, Del, Ctx, File }
|
||||
|
||||
public sealed class DiffLineViewModel
|
||||
{
|
||||
@@ -16,9 +16,10 @@ public sealed class DiffLineViewModel
|
||||
public required string Text { get; init; }
|
||||
public string ClassName => Kind switch
|
||||
{
|
||||
DiffLineKind.Add => "add",
|
||||
DiffLineKind.Del => "del",
|
||||
_ => "ctx",
|
||||
DiffLineKind.Add => "add",
|
||||
DiffLineKind.Del => "del",
|
||||
DiffLineKind.File => "file",
|
||||
_ => "ctx",
|
||||
};
|
||||
|
||||
public string Sign => Kind switch
|
||||
@@ -102,90 +103,10 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
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..] : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
foreach (var file in UnifiedDiffParser.Parse(raw))
|
||||
Files.Add(file);
|
||||
|
||||
SelectedFile = Files.Count > 0 ? Files[0] : null;
|
||||
if (Files.Count == 0) StatusMessage = Loc.T("vm.diff.noChanges");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
111
src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs
Normal file
111
src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
/// Shared unified-diff parser used by both the per-task diff viewer and the
|
||||
/// combined (planning) diff viewer so they render identically.
|
||||
public static class UnifiedDiffParser
|
||||
{
|
||||
public static List<DiffFileViewModel> Parse(string? raw)
|
||||
{
|
||||
var files = new List<DiffFileViewModel>();
|
||||
if (string.IsNullOrWhiteSpace(raw)) return files;
|
||||
|
||||
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..] : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/// Flattens multiple parsed files into a single line stream, inserting a
|
||||
/// file-header row before each file so boundaries are visible in a
|
||||
/// single-pane (combined) view.
|
||||
public static List<DiffLineViewModel> Flatten(IEnumerable<DiffFileViewModel> files)
|
||||
{
|
||||
var lines = new List<DiffLineViewModel>();
|
||||
foreach (var file in files)
|
||||
{
|
||||
lines.Add(new DiffLineViewModel { Kind = DiffLineKind.File, Text = file.Path });
|
||||
foreach (var line in file.Lines)
|
||||
lines.Add(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user