feat(merge): diff Merge opens the 3-pane editor + conflict overview ruler

- The Merge button in the Diff window now hands a conflicting merge to the in-app
  3-pane editor (MergeModal routes 'conflict' through RequestConflictResolution,
  the same seam Approve uses) instead of dead-ending on a conflict message.
- Add a conflict overview ruler right of the Result pane: a proportional map of
  every conflict in the file, recolored by resolved state, click a tick to jump —
  so conflicts are findable in long files without scrolling.
- New MergeResolvedEdgeBrush token + conflictMap en/de key. Ui 128 + Loc 16 green.
This commit is contained in:
Mika Kuns
2026-06-19 11:31:34 +02:00
parent ca4377e641
commit 29a294b7f3
9 changed files with 79 additions and 8 deletions

View File

@@ -156,6 +156,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
TaskTitle = TaskTitle ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
RequestConflictResolution = RequestConflictResolution,
};
}
else if (CanDiffMergedRange)

View File

@@ -72,6 +72,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<string, string, Task>? RequestConflictResolution { get; set; }
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
@@ -99,10 +100,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
{
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
var vm = ResolveMergeVm();
vm.RequestConflictResolution = RequestConflictResolution;
await vm.InitializeAsync(TaskId, TaskTitle);
await ShowMergeModal(vm);
// The diff is stale once the worktree has been merged away — close it too.
if (vm.Merged) CloseAction?.Invoke();
// The diff is stale once the worktree merged away or a conflict opened the editor.
if (vm.Merged || vm.RoutedToResolver) CloseAction?.Invoke();
}
public async Task LoadAsync(CancellationToken ct = default)

View File

@@ -28,10 +28,17 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
/// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor
/// instead of dead-ending on the conflict message.
public Func<string, string, Task>? RequestConflictResolution { get; set; }
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
/// close itself after this modal closes.
public bool Merged { get; private set; }
/// True once a conflict has been handed off to the resolver — also a cue to close the diff window.
public bool RoutedToResolver { get; private set; }
public MergeModalViewModel(IWorkerClient worker)
{
_worker = worker;
@@ -96,9 +103,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase
});
break;
case "conflict":
HasConflict = true;
ConflictFiles = result.ConflictFiles;
ErrorMessage = Loc.T("vm.merge.conflict");
// Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
// cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
if (RequestConflictResolution is not null)
{
var branch = SelectedBranch!;
RoutedToResolver = true;
CloseAction?.Invoke();
await RequestConflictResolution(TaskId, branch);
}
else
{
HasConflict = true;
ConflictFiles = result.ConflictFiles;
ErrorMessage = Loc.T("vm.merge.conflict");
}
break;
case "blocked":
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");