Nine interfaces had exactly one implementation and no test double — they existed only to be named twice in a DI registration: IFindingsStore, IFindingsStoreLocator, IPrimeScheduleSignal, IRefineRunner, IWeekReportService, IMergeCoordinator, IMissionControlPane, IOnlineLoginService, ITaskListFilter. Consumers now depend on the concrete type; the DTO records that shared those files moved next to their implementation. IInteractiveLaunchSpecService stays — it carries 54 lines of contract documentation, which is not ceremony. IMergeCoordinator in particular had a redundant null object: MergeCoordinator with a null Handler already no-ops, and every test used the real class with Handler set. Filtering/ collapses from 8 files to 1. ITaskListFilter and TaskListFilterBase were a double abstraction over four predicates, with MatchesAsContext => false declared in both. SmartFlagFilter also compiled its expression twice (its own _flag plus the inherited Matches cache) — it now uses the cache. StaticTokenAuthProvider was in src but production uses ZitadelAuthProvider; it is a test double, so it moves to the test project. Its own test goes away with it.
193 lines
8.1 KiB
C#
193 lines
8.1 KiB
C#
using System.Collections.ObjectModel;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
|
|
|
public sealed partial class MergeModalViewModel : ViewModelBase
|
|
{
|
|
private readonly IWorkerClient _worker;
|
|
private readonly MergeCoordinator _merge;
|
|
|
|
public string TaskId { get; set; } = "";
|
|
public string TaskTitle { get; set; } = "";
|
|
|
|
public ObservableCollection<string> Branches { get; } = new();
|
|
|
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private string? _selectedBranch;
|
|
[ObservableProperty] private bool _removeWorktree = true;
|
|
[ObservableProperty] private string _commitMessage = "";
|
|
|
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
|
|
|
|
/// What the pending merge is doing right now, fed by the worker's MergeProgress broadcast.
|
|
/// A merge whose list has a verify command can occupy this call for minutes — without this the
|
|
/// modal only greys the button out and looks dead.
|
|
[ObservableProperty] private string? _progressMessage;
|
|
|
|
[ObservableProperty] private string? _errorMessage;
|
|
[ObservableProperty] private string? _warningMessage;
|
|
[ObservableProperty] private string? _successMessage;
|
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _hasConflict;
|
|
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
|
|
|
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; }
|
|
|
|
/// True once a conflict has been handed off to the resolver — also a cue to close the diff window.
|
|
public bool RoutedToResolver { get; private set; }
|
|
|
|
public MergeModalViewModel(IWorkerClient worker, MergeCoordinator merge)
|
|
{
|
|
_worker = worker;
|
|
_merge = merge;
|
|
}
|
|
|
|
public async Task InitializeAsync(string taskId, string taskTitle)
|
|
{
|
|
TaskId = taskId;
|
|
TaskTitle = taskTitle;
|
|
CommitMessage = Loc.T("vm.merge.commitMessage", taskTitle);
|
|
|
|
IsBusy = true;
|
|
try
|
|
{
|
|
var targets = await _worker.GetMergeTargetsAsync(taskId);
|
|
Branches.Clear();
|
|
if (targets is null)
|
|
{
|
|
ErrorMessage = Loc.T("vm.merge.workerOfflineBranches");
|
|
return;
|
|
}
|
|
// The worker owns the default message — only it knows the task's commit type and the
|
|
// list name the scope is slugged from. The locale string stays as the offline fallback.
|
|
if (!string.IsNullOrWhiteSpace(targets.DefaultCommitMessage))
|
|
CommitMessage = targets.DefaultCommitMessage;
|
|
foreach (var b in targets.LocalBranches) Branches.Add(b);
|
|
SelectedBranch = Branches.Contains(targets.DefaultBranch)
|
|
? targets.DefaultBranch
|
|
: Branches.FirstOrDefault();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorMessage = Loc.T("vm.merge.loadBranchesFailed", ex.Message);
|
|
}
|
|
finally { IsBusy = false; }
|
|
}
|
|
|
|
private bool CanSubmit() =>
|
|
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
|
|
|
|
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
|
private async Task SubmitAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(SelectedBranch)) return;
|
|
IsBusy = true;
|
|
ErrorMessage = null;
|
|
WarningMessage = null;
|
|
SuccessMessage = null;
|
|
// Subscribed only for the duration of the call: the worker's broadcast reaches every
|
|
// client, and a transient VM left on that event would outlive its window.
|
|
ProgressMessage = Loc.T("vm.merge.progressMerging");
|
|
_worker.MergeProgressEvent += OnMergeProgress;
|
|
_worker.OperationProgressEvent += OnOperationProgress;
|
|
try
|
|
{
|
|
var result = await _worker.MergeTaskAsync(
|
|
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage);
|
|
|
|
switch (result.Status)
|
|
{
|
|
case "merged":
|
|
Merged = true;
|
|
SuccessMessage = result.ErrorMessage is not null
|
|
? $"Merged with warning: {result.ErrorMessage}"
|
|
: Loc.T("vm.merge.merged");
|
|
// Auto-close after a short delay.
|
|
_ = Task.Run(async () =>
|
|
{
|
|
await Task.Delay(1200);
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => CloseAction?.Invoke());
|
|
});
|
|
break;
|
|
case "conflict":
|
|
// MergeTask aborted cleanly; hand the conflict to the in-app 3-pane editor,
|
|
// which re-starts the merge leaving conflicts in the tree.
|
|
RoutedToResolver = true;
|
|
CloseAction?.Invoke();
|
|
await _merge.ResolveConflictAsync(TaskId, SelectedBranch!);
|
|
break;
|
|
case "blocked":
|
|
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
|
|
break;
|
|
case "verify_failed":
|
|
// The merge landed; only the Done transition was withheld. Deliberately not
|
|
// treated as success -- no auto-close, because the failure text is the whole
|
|
// point of the gate.
|
|
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed");
|
|
break;
|
|
case "untracked_collision":
|
|
// Nothing landed -- the merge was refused before touching the repo. Show the
|
|
// real message (it names the colliding path and size); the locale string is
|
|
// only a fallback.
|
|
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.untrackedCollision");
|
|
break;
|
|
default:
|
|
ErrorMessage = Loc.T("vm.merge.unknownStatus", result.Status);
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorMessage = Loc.T("vm.merge.mergeFailed", ex.Message);
|
|
}
|
|
finally
|
|
{
|
|
_worker.MergeProgressEvent -= OnMergeProgress;
|
|
_worker.OperationProgressEvent -= OnOperationProgress;
|
|
ProgressMessage = null;
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
|
|
{
|
|
if (taskId != TaskId) return;
|
|
// Rebasing is handled by OnOperationProgress (needs the branch total, which this
|
|
// elapsed-seconds-only forwarder drops) -- don't overwrite it with the generic fallback.
|
|
if (phase == MergePhaseRebasing) return;
|
|
ProgressMessage = phase switch
|
|
{
|
|
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
|
|
_ => Loc.T("vm.merge.progressMerging"),
|
|
};
|
|
}
|
|
|
|
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
|
|
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
|
|
private void OnOperationProgress(string opKey, string phase, int current, int total)
|
|
{
|
|
if (opKey != TaskId || phase != MergePhaseRebasing) return;
|
|
ProgressMessage = total > 0
|
|
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
|
|
: Loc.T("ops.worker.rebasingAfterMerge");
|
|
}
|
|
|
|
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
|
|
private const string MergePhaseVerifying = "verifying";
|
|
|
|
/// Mirrors TaskMergeService.PhaseRebasing — a hub payload token, not a display string.
|
|
private const string MergePhaseRebasing = "rebasing";
|
|
|
|
private static string FormatElapsed(int seconds) =>
|
|
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");
|
|
|
|
[RelayCommand]
|
|
private void Cancel() => CloseAction?.Invoke();
|
|
}
|