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:
mika kuns
2026-06-04 17:56:06 +02:00
parent a3f407b0e5
commit 22a1ba7f30
7 changed files with 230 additions and 162 deletions

View File

@@ -6,7 +6,7 @@ using ClaudeDo.Ui.Localization;
namespace ClaudeDo.Ui.ViewModels.Modals; namespace ClaudeDo.Ui.ViewModels.Modals;
public enum DiffLineKind { Add, Del, Ctx } public enum DiffLineKind { Add, Del, Ctx, File }
public sealed class DiffLineViewModel public sealed class DiffLineViewModel
{ {
@@ -18,6 +18,7 @@ public sealed class DiffLineViewModel
{ {
DiffLineKind.Add => "add", DiffLineKind.Add => "add",
DiffLineKind.Del => "del", DiffLineKind.Del => "del",
DiffLineKind.File => "file",
_ => "ctx", _ => "ctx",
}; };
@@ -102,90 +103,10 @@ public sealed partial class DiffModalViewModel : ViewModelBase
return; return;
} }
// Parse unified diff — state machine over lines foreach (var file in UnifiedDiffParser.Parse(raw))
DiffFileViewModel? current = null; Files.Add(file);
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..] : "",
});
}
}
SelectedFile = Files.Count > 0 ? Files[0] : null; SelectedFile = Files.Count > 0 ? Files[0] : null;
if (Files.Count == 0) StatusMessage = Loc.T("vm.diff.noChanges"); 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;
}
}
} }

View 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;
}
}
}

View File

@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.ViewModels.Planning; namespace ClaudeDo.Ui.ViewModels.Planning;
@@ -13,6 +14,7 @@ public sealed partial class PlanningDiffViewModel : ObservableObject
private readonly string _targetBranch; private readonly string _targetBranch;
public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new(); public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();
public ObservableCollection<DiffLineViewModel> DiffLines { get; } = new();
[ObservableProperty] private SubtaskDiffRow? _selectedSubtask; [ObservableProperty] private SubtaskDiffRow? _selectedSubtask;
[ObservableProperty] private string _displayedDiff = ""; [ObservableProperty] private string _displayedDiff = "";
@@ -87,6 +89,13 @@ public sealed partial class PlanningDiffViewModel : ObservableObject
} }
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null); 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); public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);

View File

@@ -0,0 +1,82 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Controls.DiffLinesView"
x:Name="Root">
<UserControl.Styles>
<!-- diff line row tints via Tag selector (compiled-binding-friendly) -->
<Style Selector="Border.diff-line[Tag=add]">
<Setter Property="Background" Value="{StaticResource RunningTintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del]">
<Setter Property="Background" Value="{StaticResource ErrorTintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx]">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.diff-line[Tag=file]">
<Setter Property="Background" Value="{StaticResource Surface3Brush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=add] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=add] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=file] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<ItemsControl ItemsSource="{Binding #Root.Lines}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DiffLineViewModel">
<Border Classes="diff-line"
Tag="{Binding ClassName}"
Padding="4,1">
<Grid ColumnDefinitions="48,48,16,*">
<!-- Old line number -->
<TextBlock Grid.Column="0"
Text="{Binding OldNo}"
Classes="diff-lineno"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- New line number -->
<TextBlock Grid.Column="1"
Text="{Binding NewNo}"
Classes="diff-lineno"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- Sign -->
<TextBlock Grid.Column="2"
Classes="diff-sign"
Text="{Binding Sign}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"/>
<!-- Line text -->
<TextBlock Grid.Column="3"
Classes="diff-text"
Text="{Binding Text}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
TextWrapping="NoWrap"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</UserControl>

View File

@@ -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<IEnumerable?> LinesProperty =
AvaloniaProperty.Register<DiffLinesView, IEnumerable?>(nameof(Lines));
public IEnumerable? Lines
{
get => GetValue(LinesProperty);
set => SetValue(LinesProperty, value);
}
public DiffLinesView() => InitializeComponent();
}

View File

@@ -18,37 +18,6 @@
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/> <KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Window.Styles>
<!-- diff line row tints via Tag selector (compiled-binding-friendly) -->
<Style Selector="Border.diff-line[Tag=add]">
<Setter Property="Background" Value="{StaticResource RunningTintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del]">
<Setter Property="Background" Value="{StaticResource ErrorTintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx]">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.diff-line[Tag=add] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=add] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}"/>
</Style>
</Window.Styles>
<ctl:ModalShell Title="{loc:Tr modals.diff.title}" CloseCommand="{Binding CloseCommand}"> <ctl:ModalShell Title="{loc:Tr modals.diff.title}" CloseCommand="{Binding CloseCommand}">
<ctl:ModalShell.Footer> <ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8" <StackPanel Orientation="Horizontal" Spacing="8"
@@ -99,43 +68,7 @@
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" <ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"> VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding SelectedFile.Lines}"> <ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DiffLineViewModel">
<Border Classes="diff-line"
Tag="{Binding ClassName}"
Padding="4,1">
<Grid ColumnDefinitions="48,48,16,*">
<!-- Old line number -->
<TextBlock Grid.Column="0"
Text="{Binding OldNo}"
Classes="diff-lineno"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- New line number -->
<TextBlock Grid.Column="1"
Text="{Binding NewNo}"
Classes="diff-lineno"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- Sign -->
<TextBlock Grid.Column="2"
Classes="diff-sign"
Text="{Binding Sign}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"/>
<!-- Line text -->
<TextBlock Grid.Column="3"
Classes="diff-text"
Text="{Binding Text}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
TextWrapping="NoWrap"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -66,14 +66,7 @@
<Grid Grid.Column="1" Background="{DynamicResource VoidBrush}"> <Grid Grid.Column="1" Background="{DynamicResource VoidBrush}">
<ScrollViewer HorizontalScrollBarVisibility="Auto" <ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"> VerticalScrollBarVisibility="Auto">
<TextBox Text="{Binding DisplayedDiff, Mode=OneWay}" <ctl:DiffLinesView Lines="{Binding DiffLines}"/>
IsReadOnly="True"
AcceptsReturn="True"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeBody}"
Background="Transparent"
BorderThickness="0"
Padding="8"/>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>