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:
@@ -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()
|
||||
{
|
||||
|
||||
154
src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs
Normal file
154
src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user