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>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||||
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
||||||
Controls that need mono opt in via their own class/style. -->
|
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.Desktop" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" 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">
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ ViewModels/
|
|||||||
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
||||||
WorktreesOverview, UnifiedDiffParser
|
WorktreesOverview, UnifiedDiffParser
|
||||||
Planning/ — PlanningDiffViewModel, ConflictResolutionViewModel
|
Planning/ — PlanningDiffViewModel, ConflictResolutionViewModel
|
||||||
Conflicts/ — ConflictResolverViewModel
|
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
||||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||||
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
|
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
|
||||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge
|
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`.
|
- **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`.
|
- **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`.
|
- **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
|
## Services
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
<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="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
|||||||
@@ -5,45 +5,74 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
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 Ours { get; }
|
||||||
public string Theirs { get; }
|
|
||||||
public string? Base { get; }
|
public string? Base { get; }
|
||||||
|
public string Theirs { get; }
|
||||||
|
|
||||||
[ObservableProperty] private string? _resolution;
|
[ObservableProperty] private string? _resolution;
|
||||||
|
|
||||||
public bool IsResolved => Resolution is not null;
|
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;
|
Ours = ours;
|
||||||
Theirs = theirs;
|
|
||||||
Base = @base;
|
Base = @base;
|
||||||
|
Theirs = theirs;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
||||||
|
|
||||||
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
[RelayCommand] private void AcceptOurs() => Resolution = Ours;
|
||||||
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
[RelayCommand] private void AcceptTheirs() => Resolution = Theirs;
|
||||||
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||||
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
[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 bool IsConflict { get; }
|
||||||
public IReadOnlyList<ConflictHunk> Hunks { 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;
|
IsConflict = isConflict;
|
||||||
Hunks = hunks;
|
StableText = stableText;
|
||||||
|
Conflict = conflict;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
public static MergeFileSegment Stable(string text) => new(false, text, null);
|
||||||
|
public static MergeFileSegment FromConflict(MergeConflictBlock block) => new(true, "", block);
|
||||||
/// <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));
|
/// <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;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -14,23 +15,61 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly string _taskId;
|
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 bool _isBusy;
|
||||||
[ObservableProperty] private string? _error;
|
[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 string TaskId => _taskId;
|
||||||
public Action? CloseRequested { get; set; }
|
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)
|
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_taskId = taskId;
|
_taskId = taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
|
||||||
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
/// Returns true when there is something to resolve (caller should show the dialog).</summary>
|
||||||
public async Task<bool> OpenAsync(string targetBranch)
|
public async Task<bool> OpenAsync(string targetBranch)
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
@@ -45,19 +84,24 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
var docs = await _worker.GetMergeConflictDocumentsAsync(_taskId);
|
||||||
Files.Clear();
|
Files.Clear();
|
||||||
foreach (var f in conflicts.Files)
|
_flat.Clear();
|
||||||
|
foreach (var f in docs.Files)
|
||||||
{
|
{
|
||||||
var hunks = f.Hunks.Select(h =>
|
var segments = f.Segments.Select(s => s.IsConflict
|
||||||
{
|
? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
|
||||||
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
|
: MergeFileSegment.Stable(s.Text)).ToList();
|
||||||
hk.PropertyChanged += OnHunkChanged;
|
var file = new MergeFile(f.Path, f.IsBinary, segments);
|
||||||
return hk;
|
Files.Add(file);
|
||||||
}).ToList();
|
foreach (var c in file.Conflicts) _flat.Add((file, c));
|
||||||
Files.Add(new ConflictFile(f.Path, hunks));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(TotalConflicts));
|
||||||
|
OnPropertyChanged(nameof(BinaryFilePaths));
|
||||||
|
OnPropertyChanged(nameof(HasBinaryFiles));
|
||||||
RecomputeCanContinue();
|
RecomputeCanContinue();
|
||||||
|
if (_flat.Count > 0) MoveTo(0);
|
||||||
return Files.Count > 0;
|
return Files.Count > 0;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -68,14 +112,41 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
finally { IsBusy = false; }
|
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))
|
block.PropertyChanged += OnBlockChanged;
|
||||||
RecomputeCanContinue();
|
return block;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RecomputeCanContinue()
|
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
{
|
||||||
|
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]
|
[RelayCommand]
|
||||||
private async Task ContinueAsync()
|
private async Task ContinueAsync()
|
||||||
@@ -85,8 +156,8 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
Error = null;
|
Error = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var file in Files)
|
foreach (var file in Files.Where(f => !f.IsBinary))
|
||||||
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.Compose());
|
||||||
|
|
||||||
var result = await _worker.ContinueConflictMergeAsync(_taskId);
|
var result = await _worker.ContinueConflictMergeAsync(_taskId);
|
||||||
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
|
xmlns:ae="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:DataType="vm:ConflictResolverViewModel"
|
x:DataType="vm:ConflictResolverViewModel"
|
||||||
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||||
Title="{loc:Tr conflictResolver.windowTitle}"
|
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||||
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
Width="1120" Height="760" MinWidth="840" MinHeight="540"
|
||||||
CanResize="True"
|
CanResize="True"
|
||||||
WindowDecorations="BorderOnly"
|
WindowDecorations="BorderOnly"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
@@ -18,65 +19,126 @@
|
|||||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||||
</Window.KeyBindings>
|
</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 Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||||
<ctl:ModalShell.Footer>
|
<ctl:ModalShell.Footer>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
Text="{Binding ContinueHint}"
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
IsVisible="{Binding HasBinaryFiles}"/>
|
||||||
</StackPanel>
|
<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>
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*,Auto,Auto,*">
|
||||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
|
||||||
Text="{loc:Tr conflictResolver.loading}"
|
<!-- Busy / error -->
|
||||||
IsVisible="{Binding IsBusy}"/>
|
<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}"
|
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||||
Text="{Binding Error}" TextWrapping="Wrap"
|
Text="{Binding Error}" TextWrapping="Wrap"
|
||||||
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<!-- Binary-conflict banner -->
|
||||||
<ItemsControl ItemsSource="{Binding Files}">
|
<Border Grid.Row="1" Margin="0,0,0,8" Padding="10,7" CornerRadius="6"
|
||||||
<ItemsControl.ItemTemplate>
|
Background="{DynamicResource ErrorTintBrush}"
|
||||||
<DataTemplate x:DataType="vm:ConflictFile">
|
BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
|
||||||
<StackPanel Spacing="8" Margin="0,0,0,16">
|
IsVisible="{Binding HasBinaryFiles}">
|
||||||
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
<StackPanel Spacing="3">
|
||||||
<ItemsControl ItemsSource="{Binding Hunks}">
|
<TextBlock Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||||
<ItemsControl.ItemTemplate>
|
Text="Binary files can't be merged here — abort and resolve them in your editor:"/>
|
||||||
<DataTemplate x:DataType="vm:ConflictHunk">
|
<ItemsControl ItemsSource="{Binding BinaryFilePaths}">
|
||||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
<ItemsControl.ItemTemplate>
|
||||||
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
<DataTemplate x:DataType="x:String">
|
||||||
<StackPanel Spacing="6">
|
<TextBlock Classes="path-mono" Text="{Binding}"/>
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
</DataTemplate>
|
||||||
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
</ItemsControl.ItemTemplate>
|
||||||
AcceptsReturn="True" MaxHeight="120"/>
|
</ItemsControl>
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
</StackPanel>
|
||||||
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
</Border>
|
||||||
AcceptsReturn="True" MaxHeight="120"/>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<!-- Navigation header -->
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,*,Auto" Margin="0,0,0,8"
|
||||||
Command="{Binding AcceptCurrentCommand}"/>
|
IsVisible="{Binding HasCurrent}">
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
<Button Grid.Column="0" Classes="btn" Content="◀ Prev" Margin="0,0,6,0"
|
||||||
Command="{Binding AcceptIncomingCommand}"/>
|
Command="{Binding PreviousCommand}"/>
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
<Button Grid.Column="1" Classes="btn" Content="Next ▶"
|
||||||
Command="{Binding AcceptBothCommand}"/>
|
Command="{Binding NextCommand}"/>
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
<TextBlock Grid.Column="2" Classes="path-mono" VerticalAlignment="Center"
|
||||||
Command="{Binding EditManuallyCommand}"/>
|
Margin="14,0" TextTrimming="CharacterEllipsis"
|
||||||
</StackPanel>
|
Text="{Binding CurrentPath}"/>
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
<TextBlock Grid.Column="3" Classes="meta" VerticalAlignment="Center"
|
||||||
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/>
|
Text="{Binding PositionText}"/>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
<!-- Three-way columns: Base | Ours | Theirs -->
|
||||||
</ItemsControl.ItemTemplate>
|
<Grid Grid.Row="3" ColumnDefinitions="*,*,*" IsVisible="{Binding HasCurrent}">
|
||||||
</ItemsControl>
|
<Border Grid.Column="0" BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" CornerRadius="6" Margin="0,0,4,0">
|
||||||
</StackPanel>
|
<DockPanel>
|
||||||
</DataTemplate>
|
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||||
</ItemsControl.ItemTemplate>
|
<TextBlock Classes="eyebrow" Text="BASE"/>
|
||||||
</ItemsControl>
|
</Border>
|
||||||
</ScrollViewer>
|
<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>
|
</Grid>
|
||||||
</ctl:ModalShell>
|
</ctl:ModalShell>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,19 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.IO;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using AvaloniaEdit;
|
||||||
|
using AvaloniaEdit.TextMate;
|
||||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
using TextMateSharp.Grammars;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||||
|
|
||||||
public partial class ConflictResolverView : Window
|
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()
|
public ConflictResolverView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnDataContextChanged(System.EventArgs e)
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnDataContextChanged(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
|
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
|
private sealed class FakeWorker : StubWorkerClient
|
||||||
{
|
{
|
||||||
public string? WrittenPath;
|
public string? WrittenPath;
|
||||||
@@ -15,15 +24,13 @@ public class ConflictResolverViewModelTests
|
|||||||
public bool Continued;
|
public bool Continued;
|
||||||
public bool Aborted;
|
public bool Aborted;
|
||||||
public string ContinueStatus = "merged";
|
public string ContinueStatus = "merged";
|
||||||
|
public IReadOnlyList<ConflictDocumentDto> Docs = new[] { OneConflictFile() };
|
||||||
|
|
||||||
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
||||||
|
|
||||||
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
public override Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
||||||
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
|
=> Task.FromResult(new MergeConflictDocumentsDto(taskId, Docs));
|
||||||
{
|
|
||||||
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
|
|
||||||
}));
|
|
||||||
|
|
||||||
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
{
|
{
|
||||||
@@ -43,19 +50,23 @@ public class ConflictResolverViewModelTests
|
|||||||
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
|
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
|
||||||
{
|
{
|
||||||
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
||||||
|
|
||||||
var hasConflicts = await vm.OpenAsync("main");
|
var hasConflicts = await vm.OpenAsync("main");
|
||||||
|
|
||||||
Assert.True(hasConflicts);
|
Assert.True(hasConflicts);
|
||||||
var file = Assert.Single(vm.Files);
|
var file = Assert.Single(vm.Files);
|
||||||
Assert.Equal("README.md", file.Path);
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.Equal(1, vm.TotalConflicts);
|
||||||
|
Assert.NotNull(vm.Current);
|
||||||
Assert.False(vm.CanContinue);
|
Assert.False(vm.CanContinue);
|
||||||
|
|
||||||
file.Hunks[0].AcceptIncomingCommand.Execute(null);
|
vm.Current!.AcceptTheirsCommand.Execute(null);
|
||||||
Assert.True(vm.CanContinue);
|
Assert.True(vm.CanContinue);
|
||||||
|
Assert.Equal(1, vm.ResolvedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
|
public async Task Continue_WritesComposedFile_AndClosesOnMerged()
|
||||||
{
|
{
|
||||||
var worker = new FakeWorker();
|
var worker = new FakeWorker();
|
||||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
@@ -63,11 +74,12 @@ public class ConflictResolverViewModelTests
|
|||||||
vm.CloseRequested = () => closed = true;
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
await vm.OpenAsync("main");
|
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);
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
Assert.Equal("README.md", worker.WrittenPath);
|
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(worker.Continued);
|
||||||
Assert.True(closed);
|
Assert.True(closed);
|
||||||
}
|
}
|
||||||
@@ -81,13 +93,60 @@ public class ConflictResolverViewModelTests
|
|||||||
vm.CloseRequested = () => closed = true;
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
await vm.OpenAsync("main");
|
await vm.OpenAsync("main");
|
||||||
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
|
vm.Current!.AcceptBothCommand.Execute(null);
|
||||||
await vm.ContinueCommand.ExecuteAsync(null);
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
Assert.False(closed);
|
Assert.False(closed);
|
||||||
Assert.NotNull(vm.Error);
|
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]
|
[Fact]
|
||||||
public async Task Abort_CallsWorkerAndCloses()
|
public async Task Abort_CallsWorkerAndCloses()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user