MergeTargetsDto/MergeTargets bekommen TargetDirty (via HasChangesAsync, includeUntracked: false). Das Merge-Modal zeigt Warnung + Stash-Retry sofort in InitializeAsync statt erst nach dem dirty_target-Fehlschlag; der REVIEW-Block in der Detail-Pane zeigt denselben Hinweis neben Approve. Der dirty_target-Rueckgabepfad bleibt als Absicherung bestehen. Behebt Befund F6 aus docs/test-session-2026-09-16.md.
210 lines
8.2 KiB
C#
210 lines
8.2 KiB
C#
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using System.Collections.ObjectModel;
|
|
using ClaudeDo.Ui.Localization;
|
|
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;
|
|
|
|
// Mirrors MergeTargetsDto.TargetDirty — surfaced next to Approve so a dirty target working
|
|
// dir is visible before anyone clicks, not just after the merge modal refuses it.
|
|
[ObservableProperty] private bool _targetDirty;
|
|
|
|
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
|
|
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
|
|
public OperationStatus PreviewOp { get; } = new();
|
|
|
|
[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);
|
|
|
|
// CanDiffMergedRange covers a worktree-less list-handler host task (no live worktree, but
|
|
// a HandlerBaseCommit/HandlerHeadCommit review range over the list's working dir) so its
|
|
// merge/diff card still renders even though _worktreePath stays null for it.
|
|
public bool ShowMergeSection =>
|
|
_worktreePath != null || _isPlanningParent || _hasChildOutcomes || CanDiffMergedRange;
|
|
|
|
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; }
|
|
|
|
// Mirrors ListsIslandViewModel/TasksIslandViewModel.ErrorReported — DetailsIslandViewModel
|
|
// forwards this into its own ErrorReported, which the shell flashes in the footer strip.
|
|
public event Action<string>? ErrorReported;
|
|
|
|
// 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;
|
|
using var op = PreviewOp.Begin(Loc.T("ops.review.previewing"));
|
|
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;
|
|
TargetDirty = 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()
|
|
{
|
|
var (ok, error) = ShellOpen.Path(_worktreePath);
|
|
if (!ok && error is not null)
|
|
ErrorReported?.Invoke(Loc.T("vm.mergeSection.openWorktreeFailed", error));
|
|
}
|
|
|
|
private bool CanOpenWorktree() => _worktreePath != null;
|
|
}
|