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 Parse(string? raw) { var files = new List(); 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: @@ -, +, @@ 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(); 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()); } }