Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs
T
mika kuns 2aaaa23912 feat(review): gate Approve & Merge behind opening the diff
Approve & Merge is disabled until the pending changes have been inspected:
DetailsIslandViewModel tracks ReviewDiffViewed (reset on task switch and on
every state change), MergeSectionViewModel raises DiffViewed when a diff or
combined diff is opened and exposes HasReviewableDiff, and a hint sits next
to the button. A review with nothing to inspect (sandbox run, no worktree)
approves straight through.

ClaudeDo-Task: f9809a93
2026-07-24 12:52:57 +02:00

199 lines
7.1 KiB
C#

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class MergeSectionViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly IServiceProvider _services;
// Context mirrored from parent, updated via Sync* methods
internal string? TaskId { get; private set; }
internal string? TaskTitle { get; private set; }
private string? _worktreePath;
private string? _worktreeBaseCommit;
private string? _worktreeHeadCommit;
private string? _worktreeStateLabel;
private string? _listWorkingDir;
private bool _isPlanningParent;
private int _subtaskCount;
private bool _hasChildOutcomes;
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
[ObservableProperty] private string? _selectedMergeTarget;
[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 ShowMergeSection =>
_worktreePath != null || _isPlanningParent || _hasChildOutcomes;
public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
// Raised when the user opens a diff/combined-diff for the current task, so the
// review gate can record that the changes were inspected before merging.
public Action? DiffViewed { get; set; }
// True when there is something to inspect before merging (a live worktree diff,
// a merged commit range, or a planning/children combined diff). When false there
// is nothing to read, so the review gate must not block approve.
public bool HasReviewableDiff => CanOpenDiff() || CanReviewDiff();
public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services)
{
_worker = worker;
_services = services;
}
partial void OnSelectedMergeTargetChanged(string? value) => _ = RefreshMergePreviewAsync();
internal void SyncWorktree(
string? worktreePath,
string? worktreeBase,
string? worktreeHead,
string? worktreeState,
string? listWorkDir)
{
_worktreePath = worktreePath;
_worktreeBaseCommit = worktreeBase;
_worktreeHeadCommit = worktreeHead;
_worktreeStateLabel = worktreeState;
_listWorkingDir = listWorkDir;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
}
internal void SyncTaskContext(string? taskId, string? taskTitle, bool isPlanningParent)
{
TaskId = taskId;
TaskTitle = taskTitle;
_isPlanningParent = isPlanningParent;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
}
internal void SyncChildOutcomes(bool hasChildOutcomes, int subtaskCount)
{
_hasChildOutcomes = hasChildOutcomes;
_subtaskCount = subtaskCount;
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(HasReviewableDiff));
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
}
internal async System.Threading.Tasks.Task RefreshMergePreviewAsync()
{
if (TaskId is null || _worktreePath is null)
{
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
return;
}
if (_worktreeStateLabel is { } label && label != "Active")
{
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
return;
}
var capturedTaskId = TaskId;
var capturedTarget = SelectedMergeTarget;
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
if (TaskId != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
}
internal void Clear()
{
MergeTargetBranches.Clear();
SelectedMergeTarget = null;
MergePreviewText = "";
MergeIsClean = false;
MergeIsConflict = false;
SyncWorktree(null, null, null, null, null);
SyncTaskContext(null, null, false);
SyncChildOutcomes(false, 0);
}
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
{
if (TaskId is null || ShowDiffViewer is null) return;
var vm = _services.GetRequiredService<DiffViewerViewModel>();
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
await vm.LoadAsync();
DiffViewed?.Invoke();
await ShowDiffViewer(vm);
}
private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes;
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync()
{
if (ShowDiffViewer is null) return;
var hasLiveWorktree =
_worktreePath != null
&& _worktreeStateLabel == "Active"
&& System.IO.Directory.Exists(_worktreePath);
var vm = _services.GetRequiredService<DiffViewerViewModel>();
if (hasLiveWorktree)
{
vm.ConfigureWorktree(_worktreePath!, _worktreeBaseCommit, TaskId, TaskTitle ?? "");
vm.ShowMergeModal = ShowMergeModal;
vm.ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>();
}
else if (CanDiffMergedRange)
{
vm.ConfigureCommitRange(_listWorkingDir!, _worktreeBaseCommit, _worktreeHeadCommit, TaskId, TaskTitle ?? "");
}
else return;
await vm.LoadAsync();
DiffViewed?.Invoke();
await ShowDiffViewer(vm);
}
private bool CanDiffMergedRange =>
_worktreeBaseCommit != null && _worktreeHeadCommit != null && _listWorkingDir != null;
private bool CanOpenDiff() => _worktreePath != null || CanDiffMergedRange;
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
private void OpenWorktree()
{
if (_worktreePath is null) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = _worktreePath,
UseShellExecute = true,
});
}
catch { }
}
private bool CanOpenWorktree() => _worktreePath != null;
}