merge(layer-c): inline conflict resolver + worker conflict plumbing
This commit is contained in:
49
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
Normal file
49
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
|
||||
public sealed partial class ConflictHunk : ObservableObject
|
||||
{
|
||||
public string Ours { get; }
|
||||
public string Theirs { get; }
|
||||
public string? Base { get; }
|
||||
|
||||
[ObservableProperty] private string? _resolution;
|
||||
|
||||
public bool IsResolved => Resolution is not null;
|
||||
|
||||
public ConflictHunk(string ours, string theirs, string? @base)
|
||||
{
|
||||
Ours = ours;
|
||||
Theirs = theirs;
|
||||
Base = @base;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public sealed class ConflictFile
|
||||
{
|
||||
public string Path { get; }
|
||||
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
||||
|
||||
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
||||
{
|
||||
Path = path;
|
||||
Hunks = hunks;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
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;
|
||||
|
||||
public ObservableCollection<ConflictFile> Files { get; } = new();
|
||||
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string? _error;
|
||||
[ObservableProperty] private bool _canContinue;
|
||||
|
||||
public string TaskId => _taskId;
|
||||
public Action? CloseRequested { get; set; }
|
||||
|
||||
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>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
||||
Files.Clear();
|
||||
foreach (var f in conflicts.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));
|
||||
}
|
||||
RecomputeCanContinue();
|
||||
return Files.Count > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
||||
RecomputeCanContinue();
|
||||
}
|
||||
|
||||
private void RecomputeCanContinue()
|
||||
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ContinueAsync()
|
||||
{
|
||||
if (!CanContinue) return;
|
||||
IsBusy = true;
|
||||
Error = null;
|
||||
try
|
||||
{
|
||||
foreach (var file in Files)
|
||||
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
||||
|
||||
var result = await _worker.ContinueMergeAsync(_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 { await _worker.AbortMergeAsync(_taskId); }
|
||||
catch (Exception ex) { Error = ex.Message; }
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||
|
||||
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
|
||||
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
||||
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
|
||||
|
||||
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
||||
{
|
||||
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||
var vm = ConflictResolverFactory(taskId);
|
||||
var hasConflicts = await vm.OpenAsync(targetBranch);
|
||||
if (hasConflicts)
|
||||
await ShowConflictResolver(vm);
|
||||
}
|
||||
|
||||
// Set by MainWindow to open the About dialog.
|
||||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||||
|
||||
|
||||
82
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
Normal file
82
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
Normal file
@@ -0,0 +1,82 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
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"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<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>
|
||||
</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}"/>
|
||||
<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>
|
||||
</Grid>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Avalonia.Controls;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||
|
||||
public partial class ConflictResolverView : Window
|
||||
{
|
||||
public ConflictResolverView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(System.EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is ConflictResolverViewModel vm)
|
||||
vm.CloseRequested = Close;
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,11 @@ public partial class MainWindow : Window
|
||||
connVm.CloseAction = () => dlg.Close();
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
vm.ShowConflictResolver = async (resolverVm) =>
|
||||
{
|
||||
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user