feat(merge): in-app 3-way merge editor (chunk 2b)
Replace the whole-file conflict resolver with a real 3-way merge editor built on the line-level hunk pipeline. - ConflictModels: MergeFile/MergeFileSegment/MergeConflictBlock with Compose() that reassembles stable text + chosen resolutions - ConflictResolverViewModel (same seam contract): loads conflict documents, flattens conflicts for one-at-a-time navigation, per-block Accept Ours/Base/Theirs/Both + editable result, binary files block continue - ConflictResolverView: 3-column Base|Ours|Theirs + editable result via AvaloniaEdit with TextMate syntax highlighting by file extension; editors synced in code-behind - add Avalonia.AvaloniaEdit + AvaloniaEdit.TextMate + TextMateSharp.Grammars; AvaloniaEdit theme StyleInclude in App.axaml - rewrite ConflictResolverViewModel tests (load/gating/compose/nav/binary/abort)
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
||||
Controls that need mono opt in via their own class/style. -->
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<!-- Direct ref so the App.axaml AvaloniaEdit theme (avares://AvaloniaEdit/...) resolves at runtime. -->
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
|
||||
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
|
||||
@@ -5,45 +5,74 @@ using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
|
||||
public sealed partial class ConflictHunk : ObservableObject
|
||||
/// <summary>
|
||||
/// One conflict region in a file: the two competing versions (and the merge base when the
|
||||
/// merge used diff3 style), plus the chosen <see cref="Resolution"/> (null until resolved).
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>An ordered piece of a conflicted file: either stable common text or a conflict block.</summary>
|
||||
public sealed class MergeFileSegment
|
||||
{
|
||||
public string Path { get; }
|
||||
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
||||
public bool IsConflict { get; }
|
||||
public string StableText { get; }
|
||||
public MergeConflictBlock? Conflict { get; }
|
||||
|
||||
public ConflictFile(string path, IReadOnlyList<ConflictHunk> 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);
|
||||
|
||||
/// <summary>Merged file content: concatenation of each hunk's resolution
|
||||
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>A conflicted file: its ordered segments (for reassembly) and just its conflict blocks.</summary>
|
||||
public sealed class MergeFile
|
||||
{
|
||||
public string Path { get; }
|
||||
public bool IsBinary { get; }
|
||||
public IReadOnlyList<MergeFileSegment> Segments { get; }
|
||||
public IReadOnlyList<MergeConflictBlock> Conflicts { get; }
|
||||
|
||||
public MergeFile(string path, bool isBinary, IReadOnlyList<MergeFileSegment> segments)
|
||||
{
|
||||
Path = path;
|
||||
IsBinary = isBinary;
|
||||
Segments = segments;
|
||||
Conflicts = segments.Where(s => s.IsConflict).Select(s => s.Conflict!).ToList();
|
||||
}
|
||||
|
||||
/// <summary>A binary file can't be resolved in-app; a text file is done once every block is resolved.</summary>
|
||||
public bool AllResolved => !IsBinary && Conflicts.All(c => c.IsResolved);
|
||||
|
||||
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution.</summary>
|
||||
public string Compose() => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? s.Conflict.Ours) : s.StableText));
|
||||
}
|
||||
|
||||
@@ -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<ConflictFile> Files { get; } = new();
|
||||
public ObservableCollection<MergeFile> 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<string> 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; }
|
||||
|
||||
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
|
||||
public event Action? CurrentChanged;
|
||||
|
||||
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||
{
|
||||
_worker = worker;
|
||||
_taskId = taskId;
|
||||
}
|
||||
|
||||
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
||||
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
||||
/// <summary>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).</summary>
|
||||
public async Task<bool> 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))
|
||||
|
||||
@@ -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 @@
|
||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="ae|TextEditor">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
<Setter Property="WordWrap" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="Border.col-head">
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||
<ctl:ModalShell.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding ContinueHint}"
|
||||
IsVisible="{Binding HasBinaryFiles}"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn accent" Content="{loc:Tr conflictResolver.continue}"
|
||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
||||
Text="{loc:Tr conflictResolver.loading}"
|
||||
IsVisible="{Binding IsBusy}"/>
|
||||
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*,Auto,Auto,*">
|
||||
|
||||
<!-- Busy / error -->
|
||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
|
||||
Text="{loc:Tr conflictResolver.loading}" IsVisible="{Binding IsBusy}"/>
|
||||
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding Error}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Files}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictFile">
|
||||
<StackPanel Spacing="8" Margin="0,0,0,16">
|
||||
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
||||
<ItemsControl ItemsSource="{Binding Hunks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictHunk">
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
||||
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
||||
AcceptsReturn="True" MaxHeight="120"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
||||
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
||||
AcceptsReturn="True" MaxHeight="120"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
||||
Command="{Binding AcceptCurrentCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
||||
Command="{Binding AcceptIncomingCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
||||
Command="{Binding AcceptBothCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
||||
Command="{Binding EditManuallyCommand}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
||||
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
||||
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<!-- Binary-conflict banner -->
|
||||
<Border Grid.Row="1" Margin="0,0,0,8" Padding="10,7" CornerRadius="6"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
|
||||
IsVisible="{Binding HasBinaryFiles}">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||
Text="Binary files can't be merged here — abort and resolve them in your editor:"/>
|
||||
<ItemsControl ItemsSource="{Binding BinaryFilePaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Classes="path-mono" Text="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Navigation header -->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,8"
|
||||
IsVisible="{Binding HasCurrent}">
|
||||
<Button Grid.Column="0" Classes="btn" Content="◀ Prev" Margin="0,0,6,0"
|
||||
Command="{Binding PreviousCommand}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="Next ▶"
|
||||
Command="{Binding NextCommand}"/>
|
||||
<TextBlock Grid.Column="2" Classes="path-mono" VerticalAlignment="Center"
|
||||
Margin="14,0" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding CurrentPath}"/>
|
||||
<TextBlock Grid.Column="3" Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
Text="{Binding PositionText}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Three-way columns: Base | Ours | Theirs -->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,*,*" IsVisible="{Binding HasCurrent}">
|
||||
<Border Grid.Column="0" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="0,0,4,0">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="BASE"/>
|
||||
</Border>
|
||||
<ae:TextEditor Name="BaseEditor" IsReadOnly="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="4,0">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="OURS · current (merge target)" Foreground="{DynamicResource MossBrush}"/>
|
||||
</Border>
|
||||
<ae:TextEditor Name="OursEditor" IsReadOnly="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="4,0,0,0">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="THEIRS · incoming (task)" Foreground="{DynamicResource AccentBrush}"/>
|
||||
</Border>
|
||||
<ae:TextEditor Name="TheirsEditor" IsReadOnly="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Accept actions -->
|
||||
<WrapPanel Grid.Row="4" Orientation="Horizontal" Margin="0,8" IsVisible="{Binding HasCurrent}">
|
||||
<Button Classes="btn" Content="Accept Ours" Margin="0,0,8,0"
|
||||
Command="{Binding Current.AcceptOursCommand}"/>
|
||||
<Button Classes="btn" Content="Accept Base" Margin="0,0,8,0"
|
||||
IsEnabled="{Binding Current.HasBase}"
|
||||
Command="{Binding Current.AcceptBaseCommand}"/>
|
||||
<Button Classes="btn" Content="Accept Theirs" Margin="0,0,8,0"
|
||||
Command="{Binding Current.AcceptTheirsCommand}"/>
|
||||
<Button Classes="btn" Content="Accept Both" Margin="0,0,8,0"
|
||||
Command="{Binding Current.AcceptBothCommand}"/>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- Merged result -->
|
||||
<TextBlock Grid.Row="5" Classes="eyebrow" Text="MERGED RESULT" Margin="0,4,0,4"
|
||||
IsVisible="{Binding HasCurrent}"/>
|
||||
<Border Grid.Row="6" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6"
|
||||
IsVisible="{Binding HasCurrent}">
|
||||
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
|
||||
@@ -1,19 +1,102 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using Avalonia.Controls;
|
||||
using AvaloniaEdit;
|
||||
using AvaloniaEdit.TextMate;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
using TextMateSharp.Grammars;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||
|
||||
public partial class ConflictResolverView : Window
|
||||
{
|
||||
private ConflictResolverViewModel? _vm;
|
||||
private RegistryOptions? _registry;
|
||||
private TextMate.Installation? _baseTm, _oursTm, _theirsTm, _resultTm;
|
||||
private MergeConflictBlock? _hooked;
|
||||
private bool _reloading;
|
||||
|
||||
public ConflictResolverView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(System.EventArgs e)
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is ConflictResolverViewModel vm)
|
||||
vm.CloseRequested = Close;
|
||||
|
||||
if (_vm is not null) _vm.CurrentChanged -= ReloadEditors;
|
||||
_vm = DataContext as ConflictResolverViewModel;
|
||||
if (_vm is null) return;
|
||||
|
||||
_vm.CloseRequested = Close;
|
||||
EnsureTextMate();
|
||||
_vm.CurrentChanged += ReloadEditors;
|
||||
ResultEditor.TextChanged += OnResultEditorChanged;
|
||||
ReloadEditors();
|
||||
}
|
||||
|
||||
private void EnsureTextMate()
|
||||
{
|
||||
if (_registry is not null) return;
|
||||
_registry = new RegistryOptions(ThemeName.DarkPlus);
|
||||
_baseTm = BaseEditor.InstallTextMate(_registry);
|
||||
_oursTm = OursEditor.InstallTextMate(_registry);
|
||||
_theirsTm = TheirsEditor.InstallTextMate(_registry);
|
||||
_resultTm = ResultEditor.InstallTextMate(_registry);
|
||||
}
|
||||
|
||||
private void ReloadEditors()
|
||||
{
|
||||
if (_vm is null) return;
|
||||
_reloading = true;
|
||||
try
|
||||
{
|
||||
if (_hooked is not null) _hooked.PropertyChanged -= OnCurrentResolutionChanged;
|
||||
_hooked = _vm.Current;
|
||||
if (_hooked is not null) _hooked.PropertyChanged += OnCurrentResolutionChanged;
|
||||
|
||||
BaseEditor.Text = _vm.Current?.Base ?? "";
|
||||
OursEditor.Text = _vm.Current?.Ours ?? "";
|
||||
TheirsEditor.Text = _vm.Current?.Theirs ?? "";
|
||||
ResultEditor.Text = _vm.Current?.Resolution ?? "";
|
||||
ApplyGrammar(_vm.CurrentPath);
|
||||
}
|
||||
finally { _reloading = false; }
|
||||
}
|
||||
|
||||
// User edits in the result editor flow back to the current conflict's resolution.
|
||||
private void OnResultEditorChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_reloading || _vm?.Current is null) return;
|
||||
_vm.Current.Resolution = ResultEditor.Text;
|
||||
}
|
||||
|
||||
// Accept-buttons set Resolution on the VM; mirror that into the result editor.
|
||||
private void OnCurrentResolutionChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (_reloading || e.PropertyName != nameof(MergeConflictBlock.Resolution)) return;
|
||||
var resolved = _vm?.Current?.Resolution ?? "";
|
||||
if (ResultEditor.Text == resolved) return;
|
||||
_reloading = true;
|
||||
try { ResultEditor.Text = resolved; }
|
||||
finally { _reloading = false; }
|
||||
}
|
||||
|
||||
private void ApplyGrammar(string? path)
|
||||
{
|
||||
if (_registry is null || string.IsNullOrEmpty(path)) return;
|
||||
var ext = Path.GetExtension(path);
|
||||
if (string.IsNullOrEmpty(ext)) return;
|
||||
|
||||
var language = _registry.GetLanguageByExtension(ext);
|
||||
if (language is null) return;
|
||||
|
||||
var scope = _registry.GetScopeByLanguageId(language.Id);
|
||||
_baseTm?.SetGrammar(scope);
|
||||
_oursTm?.SetGrammar(scope);
|
||||
_theirsTm?.SetGrammar(scope);
|
||||
_resultTm?.SetGrammar(scope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class ConflictResolverViewModelTests
|
||||
{
|
||||
// A file split as: stable "a\n", one conflict, stable "z\n".
|
||||
private static ConflictDocumentDto OneConflictFile(string path = "README.md") =>
|
||||
new(path, false, new[]
|
||||
{
|
||||
new MergeSegmentDto(false, "a\n", "", null, ""),
|
||||
new MergeSegmentDto(true, "", "ours\n", "base\n", "theirs\n"),
|
||||
new MergeSegmentDto(false, "z\n", "", null, ""),
|
||||
});
|
||||
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public string? WrittenPath;
|
||||
@@ -15,15 +24,13 @@ public class ConflictResolverViewModelTests
|
||||
public bool Continued;
|
||||
public bool Aborted;
|
||||
public string ContinueStatus = "merged";
|
||||
public IReadOnlyList<ConflictDocumentDto> Docs = new[] { OneConflictFile() };
|
||||
|
||||
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
||||
|
||||
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
|
||||
{
|
||||
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
|
||||
}));
|
||||
public override Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
||||
=> Task.FromResult(new MergeConflictDocumentsDto(taskId, Docs));
|
||||
|
||||
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||
{
|
||||
@@ -43,19 +50,23 @@ public class ConflictResolverViewModelTests
|
||||
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
|
||||
{
|
||||
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
||||
|
||||
var hasConflicts = await vm.OpenAsync("main");
|
||||
|
||||
Assert.True(hasConflicts);
|
||||
var file = Assert.Single(vm.Files);
|
||||
Assert.Equal("README.md", file.Path);
|
||||
Assert.Equal(1, vm.TotalConflicts);
|
||||
Assert.NotNull(vm.Current);
|
||||
Assert.False(vm.CanContinue);
|
||||
|
||||
file.Hunks[0].AcceptIncomingCommand.Execute(null);
|
||||
vm.Current!.AcceptTheirsCommand.Execute(null);
|
||||
Assert.True(vm.CanContinue);
|
||||
Assert.Equal(1, vm.ResolvedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
|
||||
public async Task Continue_WritesComposedFile_AndClosesOnMerged()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||
@@ -63,11 +74,12 @@ public class ConflictResolverViewModelTests
|
||||
vm.CloseRequested = () => closed = true;
|
||||
|
||||
await vm.OpenAsync("main");
|
||||
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null);
|
||||
vm.Current!.AcceptOursCommand.Execute(null); // choose "ours\n" for the single conflict
|
||||
await vm.ContinueCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("README.md", worker.WrittenPath);
|
||||
Assert.Equal("ours\n", worker.WrittenContent);
|
||||
// stable "a\n" + chosen "ours\n" + stable "z\n"
|
||||
Assert.Equal("a\nours\nz\n", worker.WrittenContent);
|
||||
Assert.True(worker.Continued);
|
||||
Assert.True(closed);
|
||||
}
|
||||
@@ -81,13 +93,60 @@ public class ConflictResolverViewModelTests
|
||||
vm.CloseRequested = () => closed = true;
|
||||
|
||||
await vm.OpenAsync("main");
|
||||
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
|
||||
vm.Current!.AcceptBothCommand.Execute(null);
|
||||
await vm.ContinueCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(closed);
|
||||
Assert.NotNull(vm.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Navigation_MovesBetweenConflicts_AcrossTheFlattenedList()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Docs = new[]
|
||||
{
|
||||
new ConflictDocumentDto("a.cs", false, new[]
|
||||
{
|
||||
new MergeSegmentDto(true, "", "o1\n", null, "t1\n"),
|
||||
new MergeSegmentDto(false, "mid\n", "", null, ""),
|
||||
new MergeSegmentDto(true, "", "o2\n", null, "t2\n"),
|
||||
}),
|
||||
},
|
||||
};
|
||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||
|
||||
await vm.OpenAsync("main");
|
||||
|
||||
Assert.Equal(2, vm.TotalConflicts);
|
||||
Assert.Equal("o1\n", vm.Current!.Ours);
|
||||
Assert.False(vm.PreviousCommand.CanExecute(null));
|
||||
Assert.True(vm.NextCommand.CanExecute(null));
|
||||
|
||||
vm.NextCommand.Execute(null);
|
||||
Assert.Equal("o2\n", vm.Current!.Ours);
|
||||
Assert.False(vm.NextCommand.CanExecute(null));
|
||||
Assert.True(vm.PreviousCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BinaryConflict_BlocksContinue()
|
||||
{
|
||||
var worker = new FakeWorker
|
||||
{
|
||||
Docs = new[] { new ConflictDocumentDto("logo.png", true, System.Array.Empty<MergeSegmentDto>()) },
|
||||
};
|
||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||
|
||||
var hasConflicts = await vm.OpenAsync("main");
|
||||
|
||||
Assert.True(hasConflicts); // file is shown
|
||||
Assert.True(vm.HasBinaryFiles);
|
||||
Assert.Equal(0, vm.TotalConflicts); // nothing to resolve in-app
|
||||
Assert.False(vm.CanContinue); // binary blocks continue
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abort_CallsWorkerAndCloses()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user