From 22a1ba7f3092f4b2a080bc2b9beb1b798edd120d Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 4 Jun 2026 17:56:06 +0200 Subject: [PATCH] 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 --- .../ViewModels/Modals/DiffModalViewModel.cs | 93 ++------------- .../ViewModels/Modals/UnifiedDiffParser.cs | 111 ++++++++++++++++++ .../Planning/PlanningDiffViewModel.cs | 9 ++ .../Views/Controls/DiffLinesView.axaml | 82 +++++++++++++ .../Views/Controls/DiffLinesView.axaml.cs | 19 +++ .../Views/Modals/DiffModalView.axaml | 69 +---------- .../Views/Planning/PlanningDiffView.axaml | 9 +- 7 files changed, 230 insertions(+), 162 deletions(-) create mode 100644 src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs create mode 100644 src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml create mode 100644 src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs index 13ad168..d8f1129 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs @@ -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: @@ -, +, @@ - 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; - } - } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs new file mode 100644 index 0000000..b1e216e --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs @@ -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 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" + 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 Flatten(IEnumerable files) + { + var lines = new List(); + 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: @@ -, +, @@ + 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; + } + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs index 2306b46..9d43382 100644 --- a/src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals; namespace ClaudeDo.Ui.ViewModels.Planning; @@ -13,6 +14,7 @@ public sealed partial class PlanningDiffViewModel : ObservableObject private readonly string _targetBranch; public ObservableCollection Subtasks { get; } = new(); + public ObservableCollection DiffLines { get; } = new(); [ObservableProperty] private SubtaskDiffRow? _selectedSubtask; [ObservableProperty] private string _displayedDiff = ""; @@ -87,6 +89,13 @@ public sealed partial class PlanningDiffViewModel : ObservableObject } partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null); + + partial void OnDisplayedDiffChanged(string value) + { + DiffLines.Clear(); + foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(value))) + DiffLines.Add(line); + } } public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff); diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml b/src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml new file mode 100644 index 0000000..d2149b0 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs new file mode 100644 index 0000000..340541c --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml.cs @@ -0,0 +1,19 @@ +using System.Collections; +using Avalonia; +using Avalonia.Controls; + +namespace ClaudeDo.Ui.Views.Controls; + +public partial class DiffLinesView : UserControl +{ + public static readonly StyledProperty LinesProperty = + AvaloniaProperty.Register(nameof(Lines)); + + public IEnumerable? Lines + { + get => GetValue(LinesProperty); + set => SetValue(LinesProperty, value); + } + + public DiffLinesView() => InitializeComponent(); +} diff --git a/src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml index 85a30a6..a12606a 100644 --- a/src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/DiffModalView.axaml @@ -18,37 +18,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml b/src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml index 4c7f3cf..f82b945 100644 --- a/src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml +++ b/src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml @@ -66,14 +66,7 @@ - +