feat(ui): diff modal with file sidebar and tinted hunks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-20 10:30:03 +02:00
parent f94bb35db7
commit 4d68543cf2
8 changed files with 455 additions and 5 deletions

View File

@@ -108,7 +108,8 @@ sealed class Program
sc.AddSingleton<DetailsIslandViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel( new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>())); sp.GetRequiredService<WorkerClient>(),
sp));
sc.AddSingleton<IslandsShellViewModel>(); sc.AddSingleton<IslandsShellViewModel>();
return sc.BuildServiceProvider(); return sc.BuildServiceProvider();

View File

@@ -27,6 +27,14 @@ public sealed class GitService
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
} }
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct); var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
@@ -50,6 +58,21 @@ public sealed class GitService
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
} }
public async Task<string> GetDiffAsync(string worktreePath, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "HEAD"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
// If nothing staged vs HEAD, try the index (untracked is never in diff)
if (string.IsNullOrWhiteSpace(stdout))
{
var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct);
if (e2 == 0) return s2;
}
return stdout;
}
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default) public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,

View File

@@ -4,7 +4,9 @@ using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.ViewModels.Islands;
@@ -12,6 +14,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker; private readonly WorkerClient _worker;
private readonly IServiceProvider _services;
// Current task row (set by IslandsShellViewModel via Bind) // Current task row (set by IslandsShellViewModel via Bind)
[ObservableProperty] private TaskRowViewModel? _task; [ObservableProperty] private TaskRowViewModel? _task;
@@ -35,10 +38,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// The task ID we are currently subscribed to for live log messages // The task ID we are currently subscribed to for live log messages
private string? _subscribedTaskId; private string? _subscribedTaskId;
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker) // Set by the view so OpenDiffCommand can show the modal as a dialog
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
// Set by the view so OpenWorktreeCommand can show the modal as a dialog
public Func<WorktreeModalViewModel, System.Threading.Tasks.Task>? ShowWorktreeModal { get; set; }
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
_services = services;
// Subscribe once; filter by current task id inside the handler // Subscribe once; filter by current task id inside the handler
_worker.TaskMessageEvent += OnTaskMessage; _worker.TaskMessageEvent += OnTaskMessage;
@@ -99,6 +109,38 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed }); Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
} }
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync()
{
if (WorktreePath == null || ShowDiffModal == null) return;
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
{
WorktreePath = WorktreePath,
};
await diffVm.LoadAsync();
await ShowDiffModal(diffVm);
}
private bool CanOpenDiff() => WorktreePath != null;
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
private async System.Threading.Tasks.Task OpenWorktreeAsync()
{
if (WorktreePath == null || ShowWorktreeModal == null) return;
var vm = _services.GetRequiredService<WorktreeModalViewModel>();
vm.WorktreePath = WorktreePath;
await vm.LoadAsync();
await ShowWorktreeModal(vm);
}
private bool CanOpenWorktree() => WorktreePath != null;
partial void OnWorktreePathChanged(string? value)
{
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
}
[RelayCommand] [RelayCommand]
private async System.Threading.Tasks.Task SendPromptAsync() private async System.Threading.Tasks.Task SendPromptAsync()
{ {

View File

@@ -0,0 +1,154 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
namespace ClaudeDo.Ui.ViewModels.Modals;
public enum DiffLineKind { Add, Del, Ctx }
public sealed class DiffLineViewModel
{
public required DiffLineKind Kind { get; init; }
public int? OldNo { get; init; }
public int? NewNo { get; init; }
public required string Text { get; init; }
public string ClassName => Kind switch
{
DiffLineKind.Add => "add",
DiffLineKind.Del => "del",
_ => "ctx",
};
public string Sign => Kind switch
{
DiffLineKind.Add => "+",
DiffLineKind.Del => "-",
_ => " ",
};
}
public sealed class DiffFileViewModel
{
public required string Path { get; init; }
public int Additions { get; init; }
public int Deletions { get; init; }
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
}
public sealed partial class DiffModalViewModel : ViewModelBase
{
private readonly GitService _git;
public required string WorktreePath { get; init; }
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
[ObservableProperty] private DiffFileViewModel? _selectedFile;
// Injected action to close the owning Window
public Action? CloseAction { get; set; }
public DiffModalViewModel(GitService git)
{
_git = git;
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
public async Task LoadAsync(CancellationToken ct = default)
{
Files.Clear();
string raw;
try { raw = await _git.GetDiffAsync(WorktreePath, ct); }
catch { return; }
if (string.IsNullOrWhiteSpace(raw)) 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..] : "",
});
// Count additions on the file VM
}
else if (line.StartsWith('-'))
{
current.Lines.Add(new DiffLineViewModel
{
Kind = DiffLineKind.Del,
OldNo = oldLine++,
Text = line.Length > 1 ? line[1..] : "",
});
}
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;
}
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

@@ -29,8 +29,8 @@
IsVisible="{Binding BranchLine, Converter={x:Static ObjectConverters.IsNotNull}}"/> IsVisible="{Binding BranchLine, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<!-- Button row --> <!-- Button row -->
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0"> <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
<Button Classes="icon-btn" Content="Open diff"/> <Button Classes="icon-btn" Content="Open diff" Command="{Binding OpenDiffCommand}"/>
<Button Classes="icon-btn" Content="Worktree"/> <Button Classes="icon-btn" Content="Worktree" Command="{Binding OpenWorktreeCommand}"/>
<Button Classes="icon-btn" Content="Stop" Command="{Binding StopCommand}"/> <Button Classes="icon-btn" Content="Stop" Command="{Binding StopCommand}"/>
<Button Classes="icon-btn" Content="Approve &amp; merge" Command="{Binding ApproveMergeCommand}"/> <Button Classes="icon-btn" Content="Approve &amp; merge" Command="{Binding ApproveMergeCommand}"/>
</StackPanel> </StackPanel>

View File

@@ -1,8 +1,38 @@
using Avalonia.Controls; using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
public partial class AgentStripView : UserControl public partial class AgentStripView : UserControl
{ {
public AgentStripView() { InitializeComponent(); } public AgentStripView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (DataContext is DetailsIslandViewModel vm)
{
vm.ShowDiffModal = async (diffVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new DiffModalView { DataContext = diffVm };
await modal.ShowDialog(owner);
};
vm.ShowWorktreeModal = async (worktreeVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new WorktreeModalView { DataContext = worktreeVm };
worktreeVm.CloseCommand.Subscribe(_ => modal.Close());
await modal.ShowDialog(owner);
};
}
}
} }

View File

@@ -0,0 +1,174 @@
<Window 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.Modals.DiffModalView"
x:DataType="vm:DiffModalViewModel"
Title="Diff"
Width="1200" Height="800"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="Transparent"
TransparencyLevelHint="AcrylicBlur">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</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="#1A4A6B4A"/>
</Style>
<Style Selector="Border.diff-line[Tag=del]">
<Setter Property="Background" Value="#1AC87060"/>
</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>
<!-- Outer container -->
<Border CornerRadius="{StaticResource ModalCornerRadius}"
BoxShadow="{StaticResource ModalShadow}"
Background="{StaticResource SurfaceBrush}"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1"
ClipToBounds="True">
<Grid RowDefinitions="36,*">
<!-- Title bar / drag handle -->
<Border Grid.Row="0"
x:Name="TitleBar"
Background="{StaticResource Surface2Brush}"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="TitleBar_PointerPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="Diff" VerticalAlignment="Center"
FontFamily="{StaticResource MonoFamily}"
FontSize="12"
Foreground="{StaticResource TextDimBrush}"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CloseCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Body: sidebar + diff content -->
<Grid Grid.Row="1" ColumnDefinitions="240,*">
<!-- File sidebar -->
<Border Grid.Column="0"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,1,0"
Background="{StaticResource DeepBrush}">
<ListBox ItemsSource="{Binding Files}"
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:DiffFileViewModel">
<Border Padding="10,8" Background="Transparent">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Path}"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"
Foreground="{StaticResource TextDimBrush}"
TextTrimming="LeadingEllipsis"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{StaticResource MossBrightBrush}"
FontFamily="{StaticResource MonoFamily}"
FontSize="10"
Text="{Binding Additions, StringFormat='+{0}'}"/>
</Border>
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{StaticResource BloodBrush}"
FontFamily="{StaticResource MonoFamily}"
FontSize="10"
Text="{Binding Deletions, StringFormat='\u2212{0}'}"/>
</Border>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Diff content -->
<ScrollViewer Grid.Column="1"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Background="{StaticResource VoidBrush}">
<ItemsControl ItemsSource="{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"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"
Foreground="{StaticResource TextFaintBrush}"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- New line number -->
<TextBlock Grid.Column="1"
Text="{Binding NewNo}"
Classes="diff-lineno"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"
Foreground="{StaticResource TextFaintBrush}"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- Sign -->
<TextBlock Grid.Column="2"
Classes="diff-sign"
Text="{Binding Sign}"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"/>
<!-- Line text -->
<TextBlock Grid.Column="3"
Classes="diff-text"
Text="{Binding Text}"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"
TextWrapping="NoWrap"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class DiffModalView : Window
{
public DiffModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is DiffModalViewModel vm)
vm.CloseAction = Close;
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
}