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

@@ -4,7 +4,9 @@ using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.ViewModels.Islands;
@@ -12,6 +14,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly IServiceProvider _services;
// Current task row (set by IslandsShellViewModel via Bind)
[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
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;
_worker = worker;
_services = services;
// Subscribe once; filter by current task id inside the handler
_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 });
}
[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]
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;
}
}
}