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

@@ -406,6 +406,7 @@
"binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:", "binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
"prevConflict": "Vorheriger Konflikt (Umschalt+F8)", "prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
"nextConflict": "Nächster Konflikt (F8)", "nextConflict": "Nächster Konflikt (F8)",
"conflictMap": "Konflikte in dieser Datei — Marker anklicken zum Springen",
"acceptOurs": "Main hinzufügen", "acceptOurs": "Main hinzufügen",
"acceptTheirs": "Incoming hinzufügen", "acceptTheirs": "Incoming hinzufügen",
"removeOurs": "Main entfernen", "removeOurs": "Main entfernen",

View File

@@ -406,6 +406,7 @@
"binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:", "binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
"prevConflict": "Previous conflict (Shift+F8)", "prevConflict": "Previous conflict (Shift+F8)",
"nextConflict": "Next conflict (F8)", "nextConflict": "Next conflict (F8)",
"conflictMap": "Conflicts in this file — click a marker to jump",
"acceptOurs": "Add main", "acceptOurs": "Add main",
"acceptTheirs": "Add incoming", "acceptTheirs": "Add incoming",
"removeOurs": "Remove main", "removeOurs": "Remove main",

View File

@@ -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), `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — ``/`` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). - **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — ``/`` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
## Services ## Services

View File

@@ -104,8 +104,9 @@
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) --> <SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) --> <SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
<SolidColorBrush x:Key="MergeConflictTintBrush" Color="#28C87060" /> <!-- unresolved conflict (blood) --> <SolidColorBrush x:Key="MergeConflictTintBrush" Color="#28C87060" /> <!-- unresolved conflict (blood) -->
<SolidColorBrush x:Key="MergeConflictEdgeBrush" Color="#80C87060" /> <!-- unresolved conflict gutter edge --> <SolidColorBrush x:Key="MergeConflictEdgeBrush" Color="#80C87060" /> <!-- unresolved conflict gutter edge / map tick -->
<SolidColorBrush x:Key="MergeResolvedTintBrush" Color="#206FA86B" /> <!-- resolved conflict (green) --> <SolidColorBrush x:Key="MergeResolvedTintBrush" Color="#206FA86B" /> <!-- resolved conflict (green) -->
<SolidColorBrush x:Key="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) --> <SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) --> <!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->

View File

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

View File

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

View File

@@ -28,10 +28,17 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public Action? CloseAction { get; set; } 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) /// True once a merge has succeeded — lets the caller (e.g. the diff window)
/// close itself after this modal closes. /// close itself after this modal closes.
public bool Merged { get; private set; } 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) public MergeModalViewModel(IWorkerClient worker)
{ {
_worker = worker; _worker = worker;
@@ -96,9 +103,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase
}); });
break; break;
case "conflict": case "conflict":
HasConflict = true; // Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
ConflictFiles = result.ConflictFiles; // cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
ErrorMessage = Loc.T("vm.merge.conflict"); 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; break;
case "blocked": case "blocked":
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? ""); ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");

View File

@@ -149,6 +149,9 @@
<Border Classes="col-head" DockPanel.Dock="Top"> <Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.result}"/> <TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.result}"/>
</Border> </Border>
<Canvas Name="ConflictMap" DockPanel.Dock="Right" Width="13"
Background="{DynamicResource Surface2Brush}"
ToolTip.Tip="{loc:Tr conflictResolver.conflictMap}"/>
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/> <ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
</DockPanel> </DockPanel>
</Border> </Border>

View File

@@ -6,6 +6,7 @@ using System.Linq;
using System.Text; using System.Text;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
@@ -251,6 +252,7 @@ public partial class ConflictResolverView : Window
private void PositionGutters() private void PositionGutters()
{ {
ClearGutters(); ClearGutters();
PopulateConflictMap();
if (_vm?.ActiveFile is null) return; if (_vm?.ActiveFile is null) return;
var tv = ResultEditor.TextArea.TextView; var tv = ResultEditor.TextArea.TextView;
if (!tv.VisualLinesValid) if (!tv.VisualLinesValid)
@@ -298,6 +300,47 @@ public partial class ConflictResolverView : Window
canvas.Children.Add(b); canvas.Children.Add(b);
} }
// ── Conflict overview ruler (right of the result pane) ───────────────────
// A proportional map of every conflict in the active file so they're findable in
// long files without scrolling; ticks recolor by resolved state and jump on click.
private void PopulateConflictMap()
{
ConflictMap.Children.Clear();
if (_vm?.ActiveFile is null || _resultRegions.Count == 0) return;
var h = ConflictMap.Bounds.Height;
if (h <= 1) return;
var doc = ResultEditor.Document;
var totalLines = Math.Max(1, doc.LineCount);
var unresolved = BrushRes("MergeConflictEdgeBrush", Color.Parse("#80C87060"));
var resolved = BrushRes("MergeResolvedEdgeBrush", Color.Parse("#806FA86B"));
foreach (var region in _resultRegions)
{
var line = doc.GetLineByOffset(region.Start.Offset).LineNumber;
var y = (line - 1) / (double)totalLines * h;
var tick = new Rectangle
{
Width = 9,
Height = 4,
Fill = region.Block.IsResolved ? resolved : unresolved,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
};
Canvas.SetLeft(tick, 2);
Canvas.SetTop(tick, Math.Min(h - 4, Math.Max(0, y)));
var r = region;
tick.PointerPressed += (_, _) => JumpToRegion(r);
ConflictMap.Children.Add(tick);
}
}
private void JumpToRegion(ResultRegion region)
{
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
ResultEditor.ScrollToLine(line);
QueueGutters();
}
private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key); private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key);
// ── Synced vertical scroll across the three panes ───────────────────────── // ── Synced vertical scroll across the three panes ─────────────────────────
@@ -345,7 +388,7 @@ public partial class ConflictResolverView : Window
private void ApplyGrammar(string? path) private void ApplyGrammar(string? path)
{ {
if (_registry is null || string.IsNullOrEmpty(path)) return; if (_registry is null || string.IsNullOrEmpty(path)) return;
var ext = Path.GetExtension(path); var ext = System.IO.Path.GetExtension(path);
if (string.IsNullOrEmpty(ext)) return; if (string.IsNullOrEmpty(ext)) return;
var language = _registry.GetLanguageByExtension(ext); var language = _registry.GetLanguageByExtension(ext);
if (language is null) return; if (language is null) return;