Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/MergeModalViewModel.cs
Mika Kuns 29a294b7f3 feat(merge): diff Merge opens the 3-pane editor + conflict overview ruler
- The Merge button in the Diff window now hands a conflicting merge to the in-app
  3-pane editor (MergeModal routes 'conflict' through RequestConflictResolution,
  the same seam Approve uses) instead of dead-ending on a conflict message.
- Add a conflict overview ruler right of the Result pane: a proportional map of
  every conflict in the file, recolored by resolved state, click a tick to jump —
  so conflicts are findable in long files without scrolling.
- New MergeResolvedEdgeBrush token + conflictMap en/de key. Ui 128 + Loc 16 green.
2026-06-19 11:31:34 +02:00

143 lines
5.2 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;
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;
[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; }
/// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor
/// instead of dead-ending on the conflict message.
public Func<string, string, Task>? RequestConflictResolution { 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)
{
_worker = worker;
}
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;
}
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;
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":
// Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
// cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
if (RequestConflictResolution is not null)
{
var branch = SelectedBranch!;
RoutedToResolver = true;
CloseAction?.Invoke();
await RequestConflictResolution(TaskId, branch);
}
else
{
HasConflict = true;
ConflictFiles = result.ConflictFiles;
ErrorMessage = Loc.T("vm.merge.conflict");
}
break;
case "blocked":
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
break;
default:
ErrorMessage = Loc.T("vm.merge.unknownStatus", result.Status);
break;
}
}
catch (Exception ex)
{
ErrorMessage = Loc.T("vm.merge.mergeFailed", ex.Message);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}