258 lines
9.7 KiB
C#
258 lines
9.7 KiB
C#
using System.Text;
|
|
|
|
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". Paths may each be
|
|
// git-quoted (C-style, octal-escaped) when they contain non-ASCII
|
|
// bytes, and unquoted paths may themselves contain spaces, so the
|
|
// two paths can't be split on a plain ' ' - this is only a best
|
|
// effort default; "--- "/"+++ "/rename lines below correct it.
|
|
var (_, newPath) = SplitGitHeaderPaths(line["diff --git ".Length..]);
|
|
current = new DiffFileViewModel { Path = newPath };
|
|
files.Add(current);
|
|
oldLine = 0; newLine = 0;
|
|
continue;
|
|
}
|
|
|
|
if (current == null) continue;
|
|
|
|
// File-level metadata that carries the change kind.
|
|
if (line.StartsWith("new file", StringComparison.Ordinal))
|
|
{
|
|
current.Status = DiffFileStatus.Added;
|
|
continue;
|
|
}
|
|
if (line.StartsWith("deleted file", StringComparison.Ordinal))
|
|
{
|
|
current.Status = DiffFileStatus.Deleted;
|
|
continue;
|
|
}
|
|
if (line.StartsWith("rename from ", StringComparison.Ordinal))
|
|
{
|
|
current.Status = DiffFileStatus.Renamed;
|
|
current.OldPath = UnquotePath(line["rename from ".Length..]);
|
|
continue;
|
|
}
|
|
if (line.StartsWith("rename to ", StringComparison.Ordinal))
|
|
{
|
|
current.Status = DiffFileStatus.Renamed;
|
|
current.Path = UnquotePath(line["rename to ".Length..]);
|
|
continue;
|
|
}
|
|
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
|
|
line.StartsWith("GIT binary patch", StringComparison.Ordinal))
|
|
{
|
|
current.IsBinary = true;
|
|
continue;
|
|
}
|
|
|
|
if (line.StartsWith("@@ ", StringComparison.Ordinal))
|
|
{
|
|
// e.g. "@@ -10,7 +10,9 @@"
|
|
ParseHunkHeader(line, out oldLine, out newLine);
|
|
continue;
|
|
}
|
|
|
|
// "--- a/..." / "+++ b/..." each carry a single unambiguous path (unlike
|
|
// the two-paths-on-one-line "diff --git" header above), so use them to
|
|
// correct the file's identity - git also appends a disambiguating tab
|
|
// after unquoted paths that contain whitespace, which must be trimmed.
|
|
if (line.StartsWith("--- ", StringComparison.Ordinal))
|
|
{
|
|
var oldPath = UnquotePath(line[4..].TrimEnd('\t'));
|
|
if (oldPath != "/dev/null" && current.Status == DiffFileStatus.Deleted)
|
|
current.Path = StripPrefix(oldPath, "a/");
|
|
continue;
|
|
}
|
|
if (line.StartsWith("+++ ", StringComparison.Ordinal))
|
|
{
|
|
var newPath = UnquotePath(line[4..].TrimEnd('\t'));
|
|
if (newPath != "/dev/null" && current.Status != DiffFileStatus.Renamed)
|
|
current.Path = StripPrefix(newPath, "b/");
|
|
continue;
|
|
}
|
|
|
|
// Skip remaining diff metadata lines
|
|
if (line.StartsWith("index ", StringComparison.Ordinal) ||
|
|
line.StartsWith("old mode", StringComparison.Ordinal) ||
|
|
line.StartsWith("new mode", StringComparison.Ordinal) ||
|
|
line.StartsWith("similarity index", StringComparison.Ordinal) ||
|
|
line.StartsWith("copy from", StringComparison.Ordinal) ||
|
|
line.StartsWith("copy to", 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Splits a "diff --git" header's remainder ("a/<old> b/<new>", where
|
|
/// either side may be individually git-quoted) into its two paths.
|
|
private static (string OldPath, string NewPath) SplitGitHeaderPaths(string content)
|
|
{
|
|
string oldToken, newToken;
|
|
|
|
if (content.Length > 0 && content[0] == '"')
|
|
{
|
|
var close = FindClosingQuote(content, 0);
|
|
oldToken = content[..(close + 1)];
|
|
newToken = content[(close + 1)..].TrimStart(' ');
|
|
}
|
|
else
|
|
{
|
|
var quoteAt = content.IndexOf('"');
|
|
if (quoteAt >= 0)
|
|
{
|
|
// Mixed quoting: an unquoted path never contains a literal quote
|
|
// (git would have quoted it), so the quote marks the other side.
|
|
oldToken = content[..quoteAt].TrimEnd(' ');
|
|
newToken = content[quoteAt..];
|
|
}
|
|
else
|
|
{
|
|
var splitAt = content.IndexOf(" b/", StringComparison.Ordinal);
|
|
oldToken = splitAt < 0 ? content : content[..splitAt];
|
|
newToken = splitAt < 0 ? content : content[(splitAt + 1)..];
|
|
}
|
|
}
|
|
|
|
return (StripPrefix(UnquotePath(oldToken), "a/"), StripPrefix(UnquotePath(newToken), "b/"));
|
|
}
|
|
|
|
private static int FindClosingQuote(string s, int start)
|
|
{
|
|
var i = start + 1;
|
|
while (i < s.Length)
|
|
{
|
|
if (s[i] == '\\') { i += 2; continue; }
|
|
if (s[i] == '"') return i;
|
|
i++;
|
|
}
|
|
return s.Length - 1;
|
|
}
|
|
|
|
private static string StripPrefix(string path, string prefix) =>
|
|
path.StartsWith(prefix, StringComparison.Ordinal) ? path[prefix.Length..] : path;
|
|
|
|
/// Reverses git's C-style path quoting: a quoted token is wrapped in double
|
|
/// quotes with '\\', '"' and non-ASCII bytes escaped as \nnn octal sequences
|
|
/// (used whenever core.quotePath would otherwise consider the path "unusual").
|
|
private static string UnquotePath(string token)
|
|
{
|
|
if (token.Length < 2 || token[0] != '"' || token[^1] != '"')
|
|
return token;
|
|
|
|
var bytes = new List<byte>();
|
|
var end = token.Length - 1;
|
|
var i = 1;
|
|
while (i < end)
|
|
{
|
|
var c = token[i];
|
|
if (c != '\\')
|
|
{
|
|
bytes.Add((byte)c);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
i++;
|
|
if (i >= end) break;
|
|
switch (token[i])
|
|
{
|
|
case '"': bytes.Add((byte)'"'); i++; break;
|
|
case '\\': bytes.Add((byte)'\\'); i++; break;
|
|
case 'a': bytes.Add(0x07); i++; break;
|
|
case 'b': bytes.Add(0x08); i++; break;
|
|
case 'f': bytes.Add(0x0C); i++; break;
|
|
case 'n': bytes.Add((byte)'\n'); i++; break;
|
|
case 'r': bytes.Add((byte)'\r'); i++; break;
|
|
case 't': bytes.Add((byte)'\t'); i++; break;
|
|
case 'v': bytes.Add(0x0B); i++; break;
|
|
default:
|
|
if (token[i] is >= '0' and <= '7')
|
|
{
|
|
var value = 0;
|
|
var digits = 0;
|
|
while (digits < 3 && i < end && token[i] is >= '0' and <= '7')
|
|
{
|
|
value = value * 8 + (token[i] - '0');
|
|
i++;
|
|
digits++;
|
|
}
|
|
bytes.Add((byte)value);
|
|
}
|
|
else
|
|
{
|
|
bytes.Add((byte)token[i]);
|
|
i++;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return Encoding.UTF8.GetString(bytes.ToArray());
|
|
}
|
|
}
|