diff --git a/src/ClaudeDo.App/App.axaml b/src/ClaudeDo.App/App.axaml index 7411ff8..dd4a14d 100644 --- a/src/ClaudeDo.App/App.axaml +++ b/src/ClaudeDo.App/App.axaml @@ -31,6 +31,7 @@ + diff --git a/src/ClaudeDo.App/ClaudeDo.App.csproj b/src/ClaudeDo.App/ClaudeDo.App.csproj index b97a76d..1257d87 100644 --- a/src/ClaudeDo.App/ClaudeDo.App.csproj +++ b/src/ClaudeDo.App/ClaudeDo.App.csproj @@ -18,6 +18,8 @@ + + None All diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index b3494e6..eecd633 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -23,7 +23,7 @@ ViewModels/ UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree, WorktreesOverview, UnifiedDiffParser Planning/ — PlanningDiffViewModel, ConflictResolutionViewModel - Conflicts/ — ConflictResolverViewModel + Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock) Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar, DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge @@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle - **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`. - **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`. - **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`. -- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolutionViewModel` (unit-merge conflict dialog: conflicted files, open in VS Code, continue/abort planning merge), `ConflictResolverViewModel` (in-app hunk-by-hunk resolver for single-task merges: start conflict merge, resolve hunks, write resolution, continue/abort). +- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolutionViewModel` (unit-merge conflict dialog: conflicted files, open in VS Code, continue/abort planning merge), `ConflictResolverViewModel` (in-app **3-way merge editor** for single-task merges: starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`, flattens conflicts for one-at-a-time navigation, per-block Accept Ours/Base/Theirs/Both + editable result, composes each file and writes via `WriteConflictResolution`, continue/abort). The view (`Views/Conflicts/ConflictResolverView`) renders a 3-column Base|Ours|Theirs + result using **AvaloniaEdit** with TextMate syntax highlighting by file extension (theme `StyleInclude` in `App.axaml`); editors are synced in code-behind since AvaloniaEdit's `Text` isn't cleanly bindable. ## Services diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index 534a4b6..1bc9dac 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -8,6 +8,9 @@ + + + diff --git a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs index 16c538a..94c2808 100644 --- a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs +++ b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs @@ -5,45 +5,74 @@ using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Conflicts; -public sealed partial class ConflictHunk : ObservableObject +/// +/// One conflict region in a file: the two competing versions (and the merge base when the +/// merge used diff3 style), plus the chosen (null until resolved). +/// +public sealed partial class MergeConflictBlock : ObservableObject { public string Ours { get; } - public string Theirs { get; } public string? Base { get; } + public string Theirs { get; } [ObservableProperty] private string? _resolution; public bool IsResolved => Resolution is not null; + public bool HasBase => Base is not null; - public ConflictHunk(string ours, string theirs, string? @base) + public MergeConflictBlock(string ours, string? @base, string theirs) { Ours = ours; - Theirs = theirs; Base = @base; + Theirs = theirs; } partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved)); - [RelayCommand] private void AcceptCurrent() => Resolution = Ours; - [RelayCommand] private void AcceptIncoming() => Resolution = Theirs; - [RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs; - [RelayCommand] private void EditManually() => Resolution ??= Ours; + [RelayCommand] private void AcceptOurs() => Resolution = Ours; + [RelayCommand] private void AcceptTheirs() => Resolution = Theirs; + [RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs; + [RelayCommand] private void AcceptBase() => Resolution = Base ?? ""; } -public sealed class ConflictFile +/// An ordered piece of a conflicted file: either stable common text or a conflict block. +public sealed class MergeFileSegment { - public string Path { get; } - public IReadOnlyList Hunks { get; } + public bool IsConflict { get; } + public string StableText { get; } + public MergeConflictBlock? Conflict { get; } - public ConflictFile(string path, IReadOnlyList hunks) + private MergeFileSegment(bool isConflict, string stableText, MergeConflictBlock? conflict) { - Path = path; - Hunks = hunks; + IsConflict = isConflict; + StableText = stableText; + Conflict = conflict; } - public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved); - - /// Merged file content: concatenation of each hunk's resolution - /// (single whole-file hunk today; concatenation stays correct for multi-hunk later). - public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution)); + public static MergeFileSegment Stable(string text) => new(false, text, null); + public static MergeFileSegment FromConflict(MergeConflictBlock block) => new(true, "", block); +} + +/// A conflicted file: its ordered segments (for reassembly) and just its conflict blocks. +public sealed class MergeFile +{ + public string Path { get; } + public bool IsBinary { get; } + public IReadOnlyList Segments { get; } + public IReadOnlyList Conflicts { get; } + + public MergeFile(string path, bool isBinary, IReadOnlyList segments) + { + Path = path; + IsBinary = isBinary; + Segments = segments; + Conflicts = segments.Where(s => s.IsConflict).Select(s => s.Conflict!).ToList(); + } + + /// A binary file can't be resolved in-app; a text file is done once every block is resolved. + public bool AllResolved => !IsBinary && Conflicts.All(c => c.IsResolved); + + /// Reassemble the file: stable text verbatim, each conflict replaced by its resolution. + public string Compose() => string.Concat( + Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? s.Conflict.Ours) : s.StableText)); } diff --git a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs index 549760c..9434a7a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; @@ -14,23 +15,61 @@ public sealed partial class ConflictResolverViewModel : ObservableObject private readonly IWorkerClient _worker; private readonly string _taskId; - public ObservableCollection Files { get; } = new(); + 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] private bool _canContinue; + + [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; + + 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 => _flat.Count == 0 + ? "No text conflicts" + : $"Conflict {CurrentIndex + 1} of {_flat.Count} · {ResolvedCount} resolved"; + + public IReadOnlyList BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList(); + public bool HasBinaryFiles => Files.Any(f => f.IsBinary); + + 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; } - /// Starts the conflict merge and loads ours/theirs/base per file. - /// Returns true when there are conflicts to resolve (caller should show the dialog). + /// 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; @@ -45,19 +84,24 @@ public sealed partial class ConflictResolverViewModel : ObservableObject return false; } - var conflicts = await _worker.GetMergeConflictsAsync(_taskId); + var docs = await _worker.GetMergeConflictDocumentsAsync(_taskId); Files.Clear(); - foreach (var f in conflicts.Files) + _flat.Clear(); + foreach (var f in docs.Files) { - var hunks = f.Hunks.Select(h => - { - var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base); - hk.PropertyChanged += OnHunkChanged; - return hk; - }).ToList(); - Files.Add(new ConflictFile(f.Path, hunks)); + 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)); RecomputeCanContinue(); + if (_flat.Count > 0) MoveTo(0); return Files.Count > 0; } catch (Exception ex) @@ -68,14 +112,41 @@ public sealed partial class ConflictResolverViewModel : ObservableObject finally { IsBusy = false; } } - private void OnHunkChanged(object? sender, PropertyChangedEventArgs e) + private MergeConflictBlock Hook(MergeConflictBlock block) { - if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution)) - RecomputeCanContinue(); + block.PropertyChanged += OnBlockChanged; + return block; } - private void RecomputeCanContinue() - => CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved); + 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)); + } + } + + private void RecomputeCanContinue() => + CanContinue = Files.Count > 0 && Files.All(f => f.AllResolved); + + private void MoveTo(int index) + { + CurrentIndex = index; + Current = _flat[index].Block; + OnPropertyChanged(nameof(CurrentPath)); + CurrentChanged?.Invoke(); + } + + 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() @@ -85,8 +156,8 @@ public sealed partial class ConflictResolverViewModel : ObservableObject Error = null; try { - foreach (var file in Files) - await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent()); + foreach (var file in Files.Where(f => !f.IsBinary)) + await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.Compose()); var result = await _worker.ContinueConflictMergeAsync(_taskId); if (string.Equals(result.Status, "merged", StringComparison.Ordinal)) diff --git a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml index 4ed3e4c..0b9d8d6 100644 --- a/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml +++ b/src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml @@ -2,11 +2,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts" xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" + xmlns:ae="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:loc="using:ClaudeDo.Ui.Localization" x:DataType="vm:ConflictResolverViewModel" x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView" Title="{loc:Tr conflictResolver.windowTitle}" - Width="760" Height="640" MinWidth="560" MinHeight="420" + Width="1120" Height="760" MinWidth="840" MinHeight="540" CanResize="True" WindowDecorations="BorderOnly" ExtendClientAreaToDecorationsHint="True" @@ -18,65 +19,126 @@ + + + + + - -