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 IMergeCoordinator _merge; public string TaskId { get; set; } = ""; public string TaskTitle { get; set; } = ""; public ObservableCollection 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 _conflictFiles = Array.Empty(); 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, IMergeCoordinator 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; 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; ProgressMessage = null; IsBusy = false; } } private void OnMergeProgress(string taskId, string phase, int elapsedSeconds) { if (taskId != TaskId) return; ProgressMessage = phase switch { MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)), _ => Loc.T("vm.merge.progressMerging"), }; } /// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string. private const string MergePhaseVerifying = "verifying"; private static string FormatElapsed(int seconds) => TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss"); [RelayCommand] private void Cancel() => CloseAction?.Invoke(); }