feat(ui): richer diff viewer + surface child roadblocks on parents
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s

- UnifiedDiffParser detects added/deleted/renamed/binary files; diff
  modal shows a file list, binary/empty placeholders, and can diff a
  merged task by commit range after its worktree is gone
- DetailsIslandViewModel flags children needing attention (failed,
  cancelled, awaiting review, or with roadblocks) on the parent
- GitService gains worktree head-commit/range support; planning chain,
  merge orchestration, and session manager tweaks with updated tests
- refresh app/installer/worker icons

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-09 16:40:59 +02:00
co-authored by Claude Opus 4.8
parent c300f8c313
commit f21c65be18
28 changed files with 509 additions and 119 deletions
@@ -8,6 +8,8 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
public enum DiffLineKind { Add, Del, Ctx, File }
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
public sealed class DiffLineViewModel
{
public required DiffLineKind Kind { get; init; }
@@ -32,10 +34,27 @@ public sealed class DiffLineViewModel
public sealed class DiffFileViewModel
{
public required string Path { get; init; }
public required string Path { get; set; }
public string? OldPath { get; set; }
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
public bool IsBinary { get; set; }
public int Additions { get; set; }
public int Deletions { get; set; }
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
/// Single-letter badge for the file's change kind (A/M/D/R).
public string StatusCode => Status switch
{
DiffFileStatus.Added => "A",
DiffFileStatus.Deleted => "D",
DiffFileStatus.Renamed => "R",
_ => "M",
};
public bool HasLines => Lines.Count > 0;
/// A text file that produced no diff hunks (e.g. a newly added empty file).
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
}
public sealed partial class DiffModalViewModel : ViewModelBase
@@ -44,6 +63,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
public required string WorktreePath { get; init; }
public string? BaseRef { get; init; }
/// When set together with <see cref="FromCommitRange"/>, the diff is computed as
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (used as the repo
/// dir) — lets a merged task's diff be viewed after its worktree is gone.
public string? HeadCommit { get; init; }
public bool FromCommitRange { get; init; }
public string? TaskId { get; init; }
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
@@ -77,6 +101,8 @@ public sealed partial class DiffModalViewModel : ViewModelBase
var vm = ResolveMergeVm();
await vm.InitializeAsync(TaskId, TaskTitle);
await ShowMergeModal(vm);
// The diff is stale once the worktree has been merged away — close it too.
if (vm.Merged) CloseAction?.Invoke();
}
public async Task LoadAsync(CancellationToken ct = default)
@@ -87,9 +113,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
string raw;
try
{
raw = BaseRef is not null
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
: await _git.GetDiffAsync(WorktreePath, ct);
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
: BaseRef is not null
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
: await _git.GetDiffAsync(WorktreePath, ct);
}
catch (Exception ex)
{
@@ -28,6 +28,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
/// close itself after this modal closes.
public bool Merged { get; private set; }
public MergeModalViewModel(WorkerClient worker)
{
_worker = worker;
@@ -80,6 +84,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
switch (result.Status)
{
case "merged":
Merged = true;
SuccessMessage = result.ErrorMessage is not null
? $"Merged with warning: {result.ErrorMessage}"
: Loc.T("vm.merge.merged");
@@ -27,6 +27,36 @@ public static class UnifiedDiffParser
if (current == null) continue;
// File-level metadata that carries the change kind.
if (line.StartsWith("new file", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Added;
continue;
}
if (line.StartsWith("deleted file", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Deleted;
continue;
}
if (line.StartsWith("rename from ", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Renamed;
current.OldPath = line["rename from ".Length..];
continue;
}
if (line.StartsWith("rename to ", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Renamed;
current.Path = line["rename to ".Length..];
continue;
}
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
line.StartsWith("GIT binary patch", StringComparison.Ordinal))
{
current.IsBinary = true;
continue;
}
if (line.StartsWith("@@ ", StringComparison.Ordinal))
{
// e.g. "@@ -10,7 +10,9 @@"
@@ -34,13 +64,15 @@ public static class UnifiedDiffParser
continue;
}
// Skip diff metadata lines
// Skip remaining 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))
line.StartsWith("old mode", StringComparison.Ordinal) ||
line.StartsWith("new mode", StringComparison.Ordinal) ||
line.StartsWith("similarity index", StringComparison.Ordinal) ||
line.StartsWith("copy from", StringComparison.Ordinal) ||
line.StartsWith("copy to", StringComparison.Ordinal))
continue;
if (line.StartsWith('+'))