using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.ViewModels.Conflicts; public sealed partial class ConflictResolverViewModel : ObservableObject { private readonly IWorkerClient _worker; private readonly string _taskId; // The task whose conflicted working tree is read/written. For a single-task merge this is // _taskId; for a planning unit-merge it's the subtask currently being merged. private string _conflictTaskId; // When set, this is a planning unit-merge: continue/abort drive the orchestrator on the parent. private string? _planningParentId; public ObservableCollection Files { get; } = new(); // All text conflicts across all files, flattened for one-at-a-time navigation. private readonly List<(MergeFile File, MergeConflictBlock Block)> _flat = new(); [ObservableProperty] private bool _isBusy; [ObservableProperty] private string? _error; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ContinueHint))] private bool _canContinue; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasCurrent))] private MergeConflictBlock? _current; [ObservableProperty] [NotifyPropertyChangedFor(nameof(PositionText))] [NotifyPropertyChangedFor(nameof(CurrentPath))] [NotifyCanExecuteChangedFor(nameof(NextCommand))] [NotifyCanExecuteChangedFor(nameof(PreviousCommand))] private int _currentIndex = -1; [ObservableProperty] private MergeFile? _activeFile; /// Raised when the active file changes so the view can rebuild its three documents. public event Action? ActiveFileChanged; partial void OnActiveFileChanged(MergeFile? value) { ActiveFileChanged?.Invoke(); OnPropertyChanged(nameof(ActiveOursText)); OnPropertyChanged(nameof(ActiveTheirsText)); OnPropertyChanged(nameof(ActiveResultText)); OnPropertyChanged(nameof(PositionText)); // Keep the focused conflict inside the active file (e.g. when switched via the file picker). if (value is not null && (Current is null || !value.Conflicts.Contains(Current))) { var idx = _flat.FindIndex(x => x.File == value); if (idx >= 0) MoveTo(idx); } } public string ActiveOursText => ActiveFile?.OursText ?? ""; public string ActiveTheirsText => ActiveFile?.TheirsText ?? ""; public string ActiveResultText => ActiveFile?.ResultText ?? ""; public bool HasCurrent => Current is not null; public int TotalConflicts => _flat.Count; public int ResolvedCount => _flat.Count(x => x.Block.IsResolved); public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null; public string PositionText { get { if (ActiveFile is null || ActiveFile.Conflicts.Count == 0) return "No text conflicts"; var count = ActiveFile.Conflicts.Count; var resolved = ActiveFile.Conflicts.Count(c => c.IsResolved); return $"{count} {(count == 1 ? "conflict" : "conflicts")} · {resolved} resolved"; } } public IReadOnlyList BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList(); public bool HasBinaryFiles => Files.Any(f => f.IsBinary); public bool HasMultipleFiles => Files.Count > 1; /// Cross-file progress shown in the editor: how many files still have unresolved /// (or binary) conflicts, so you can see how many more need attention. public string FilesSummary { get { var total = Files.Count; if (total == 0) return ""; var unresolved = Files.Count(f => !f.AllResolved); return unresolved == 0 ? $"All {total} files resolved" : $"{unresolved} of {total} files unresolved"; } } public string ContinueHint => HasBinaryFiles ? "Binary conflicts must be resolved externally — abort and resolve in your editor." : ""; private bool InRange => CurrentIndex >= 0 && CurrentIndex < _flat.Count; public string TaskId => _taskId; public Action? CloseRequested { get; set; } /// Raised when the current conflict changes so the view can reload its editors. public event Action? CurrentChanged; public ConflictResolverViewModel(IWorkerClient worker, string taskId) { _worker = worker; _taskId = taskId; _conflictTaskId = taskId; } /// Starts the conflict merge and loads the conflicted files as line-level segments. /// Returns true when there is something to resolve (caller should show the dialog). public async Task OpenAsync(string targetBranch) { IsBusy = true; Error = null; try { var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch); if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal)) { if (string.Equals(start.Status, "blocked", StringComparison.Ordinal)) Error = start.ErrorMessage; return false; } return await LoadDocumentsAsync(); } catch (Exception ex) { Error = ex.Message; return false; } finally { IsBusy = false; } } /// Resolves a planning unit-merge conflict for . The merge is /// already mid-conflict (driven by the orchestrator), so this only loads the conflicted files; /// continue/abort hand back to the orchestrator on . public async Task OpenForPlanningAsync(string planningParentId, string subtaskId) { _planningParentId = planningParentId; _conflictTaskId = subtaskId; IsBusy = true; Error = null; try { return await LoadDocumentsAsync(); } catch (Exception ex) { Error = ex.Message; return false; } finally { IsBusy = false; } } private async Task LoadDocumentsAsync() { var docs = await _worker.GetMergeConflictDocumentsAsync(_conflictTaskId); Files.Clear(); _flat.Clear(); foreach (var f in docs.Files) { var segments = f.Segments.Select(s => s.IsConflict ? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs))) : MergeFileSegment.Stable(s.Text)).ToList(); var file = new MergeFile(f.Path, f.IsBinary, segments); Files.Add(file); foreach (var c in file.Conflicts) _flat.Add((file, c)); } OnPropertyChanged(nameof(TotalConflicts)); OnPropertyChanged(nameof(BinaryFilePaths)); OnPropertyChanged(nameof(HasBinaryFiles)); OnPropertyChanged(nameof(HasMultipleFiles)); OnPropertyChanged(nameof(FilesSummary)); RecomputeCanContinue(); if (_flat.Count > 0) MoveTo(0); // also sets ActiveFile via MoveTo else if (Files.Count > 0) ActiveFile = Files[0]; return Files.Count > 0; } private MergeConflictBlock Hook(MergeConflictBlock block) { block.PropertyChanged += OnBlockChanged; return block; } private void OnBlockChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution)) { RecomputeCanContinue(); OnPropertyChanged(nameof(ResolvedCount)); OnPropertyChanged(nameof(PositionText)); OnPropertyChanged(nameof(ActiveResultText)); OnPropertyChanged(nameof(FilesSummary)); } } private void RecomputeCanContinue() => CanContinue = Files.Count > 0 && Files.All(f => f.AllResolved); private void MoveTo(int index) { CurrentIndex = index; Current = _flat[index].Block; ActiveFile = _flat[index].File; OnPropertyChanged(nameof(CurrentPath)); CurrentChanged?.Invoke(); } [RelayCommand] private void SelectFile(MergeFile file) { // Jump to the first conflict in this file (if any); otherwise just switch the active file. var idx = _flat.FindIndex(x => x.File == file); if (idx >= 0) MoveTo(idx); else ActiveFile = file; } private bool CanGoNext() => CurrentIndex >= 0 && CurrentIndex < _flat.Count - 1; private bool CanGoPrevious() => CurrentIndex > 0; [RelayCommand(CanExecute = nameof(CanGoNext))] private void Next() => MoveTo(CurrentIndex + 1); [RelayCommand(CanExecute = nameof(CanGoPrevious))] private void Previous() => MoveTo(CurrentIndex - 1); [RelayCommand] private async Task ContinueAsync() { if (!CanContinue) return; IsBusy = true; Error = null; try { foreach (var file in Files.Where(f => !f.IsBinary)) await _worker.WriteConflictResolutionAsync(_conflictTaskId, file.Path, file.Compose()); if (_planningParentId is not null) { // Hand back to the orchestrator: it commits this subtask and drains the rest. // A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast. await _worker.ContinuePlanningMergeAsync(_planningParentId); CloseRequested?.Invoke(); return; } var result = await _worker.ContinueConflictMergeAsync(_taskId); if (string.Equals(result.Status, "merged", StringComparison.Ordinal)) CloseRequested?.Invoke(); else Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry."; } catch (Exception ex) { Error = ex.Message; } finally { IsBusy = false; } } [RelayCommand] private async Task AbortAsync() { IsBusy = true; try { if (_planningParentId is not null) await _worker.AbortPlanningMergeAsync(_planningParentId); else await _worker.AbortConflictMergeAsync(_taskId); } catch (Exception ex) { Error = ex.Message; } finally { IsBusy = false; CloseRequested?.Invoke(); } } }