feat(ui): show mergeability and surface approve conflicts in the work console
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -339,6 +339,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string? _mergeAllDisabledReason;
|
[ObservableProperty] private string? _mergeAllDisabledReason;
|
||||||
[ObservableProperty] private string? _mergeAllError;
|
[ObservableProperty] private string? _mergeAllError;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||||
|
private string _mergePreviewText = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||||
|
private bool _mergeIsClean;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||||
|
private bool _mergeIsConflict;
|
||||||
|
|
||||||
|
public bool ShowMergePreviewMuted =>
|
||||||
|
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||||||
|
|
||||||
|
public bool ShowSingleMerge =>
|
||||||
|
WorktreePath != null && Task?.IsPlanningParent != true;
|
||||||
|
|
||||||
// Claude CLI stream-json parser + buffer for partial text deltas
|
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||||
private readonly StreamLineFormatter _formatter = new();
|
private readonly StreamLineFormatter _formatter = new();
|
||||||
private readonly StringBuilder _claudeBuf = new();
|
private readonly StringBuilder _claudeBuf = new();
|
||||||
@@ -812,6 +830,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
await LoadChildOutcomesAsync(row.Id, ct);
|
await LoadChildOutcomesAsync(row.Id, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entity.Worktree != null
|
||||||
|
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
||||||
|
&& MergeTargetBranches.Count == 0)
|
||||||
|
{
|
||||||
|
var targets = await _worker.GetMergeTargetsAsync(row.Id);
|
||||||
|
if (targets != null)
|
||||||
|
{
|
||||||
|
MergeTargetBranches.Clear();
|
||||||
|
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
|
||||||
|
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await RefreshMergePreviewAsync();
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
@@ -1102,6 +1134,45 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
catch { /* best-effort refresh */ }
|
catch { /* best-effort refresh */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
||||||
|
{
|
||||||
|
if (Task is null || WorktreePath is null)
|
||||||
|
{
|
||||||
|
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only probe Active worktrees; terminal states show their label instead.
|
||||||
|
if (WorktreeStateLabel is { } label && label != "Active")
|
||||||
|
{
|
||||||
|
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dto = await _worker.PreviewMergeAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
||||||
|
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async System.Threading.Tasks.Task MergeAsync()
|
||||||
|
{
|
||||||
|
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
||||||
|
if (result.Status == "conflict")
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await RefreshMergePreviewAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* broadcast reconciles */ }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||||
{
|
{
|
||||||
@@ -1138,11 +1209,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
private bool CanOpenWorktree() => WorktreePath != null;
|
private bool CanOpenWorktree() => WorktreePath != null;
|
||||||
|
|
||||||
|
partial void OnSelectedMergeTargetChanged(string? value)
|
||||||
|
{
|
||||||
|
_ = RefreshMergePreviewAsync();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnWorktreePathChanged(string? value)
|
partial void OnWorktreePathChanged(string? value)
|
||||||
{
|
{
|
||||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||||
NotifySessionSections();
|
NotifySessionSections();
|
||||||
|
OnPropertyChanged(nameof(ShowSingleMerge));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
||||||
@@ -1362,10 +1439,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
// The hub rejects (HubException) if the task is no longer WaitingForReview
|
try
|
||||||
// — e.g. after "Merge all" folded the parent. Swallow it; the TaskUpdated
|
{
|
||||||
// broadcast reconciles the UI. An unhandled command exception would crash.
|
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||||
try { await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); }
|
if (result?.Status == "conflict")
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
catch { /* stale review action; broadcast reconciles */ }
|
catch { /* stale review action; broadcast reconciles */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs
Normal file
28
src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
/// Pure mapping from a merge-preview DTO to display text + color flags.
|
||||||
|
public static class MergePreviewPresenter
|
||||||
|
{
|
||||||
|
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
|
||||||
|
{
|
||||||
|
if (dto is null) return ("", false, false);
|
||||||
|
|
||||||
|
switch (dto.Status)
|
||||||
|
{
|
||||||
|
case "clean":
|
||||||
|
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
|
||||||
|
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
|
||||||
|
|
||||||
|
case "conflict":
|
||||||
|
var names = string.Join(", ", dto.ConflictFiles.Take(3));
|
||||||
|
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
|
||||||
|
return ($"Conflicts in {names}{more}", false, true);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ("Mergeability unknown", false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class MergePreviewPresenterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Clean_Plural()
|
||||||
|
{
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
|
||||||
|
Assert.Equal("Merges cleanly · 3 files", text);
|
||||||
|
Assert.True(clean);
|
||||||
|
Assert.False(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clean_Singular()
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
|
||||||
|
Assert.Equal("Merges cleanly · 1 file", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Conflict_ListsUpToThree()
|
||||||
|
{
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
|
||||||
|
Assert.Equal("Conflicts in a.cs, b.cs", text);
|
||||||
|
Assert.False(clean);
|
||||||
|
Assert.True(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Conflict_TruncatesWithMore()
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
|
||||||
|
Assert.Equal("Conflicts in a, b, c (+2 more)", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unavailable_IsMuted()
|
||||||
|
{
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
|
||||||
|
Assert.Equal("Mergeability unknown", text);
|
||||||
|
Assert.False(clean);
|
||||||
|
Assert.False(conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_IsEmpty()
|
||||||
|
{
|
||||||
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
|
||||||
|
Assert.Equal("", text);
|
||||||
|
Assert.False(clean);
|
||||||
|
Assert.False(conflict);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user