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:
@@ -406,6 +406,7 @@
|
||||
"binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
|
||||
"prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
|
||||
"nextConflict": "Nächster Konflikt (F8)",
|
||||
"conflictMap": "Konflikte in dieser Datei — Marker anklicken zum Springen",
|
||||
"acceptOurs": "Main hinzufügen",
|
||||
"acceptTheirs": "Incoming hinzufügen",
|
||||
"removeOurs": "Main entfernen",
|
||||
|
||||
@@ -406,6 +406,7 @@
|
||||
"binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
|
||||
"prevConflict": "Previous conflict (Shift+F8)",
|
||||
"nextConflict": "Next conflict (F8)",
|
||||
"conflictMap": "Conflicts in this file — click a marker to jump",
|
||||
"acceptOurs": "Add main",
|
||||
"acceptTheirs": "Add incoming",
|
||||
"removeOurs": "Remove main",
|
||||
|
||||
@@ -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), `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
|
||||
|
||||
|
||||
@@ -104,8 +104,9 @@
|
||||
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
|
||||
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
|
||||
<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="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
|
||||
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
|
||||
|
||||
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||
|
||||
@@ -156,6 +156,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
TaskTitle = TaskTitle ?? "",
|
||||
ShowMergeModal = ShowMergeModal,
|
||||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||
RequestConflictResolution = RequestConflictResolution,
|
||||
};
|
||||
}
|
||||
else if (CanDiffMergedRange)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -149,6 +149,9 @@
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.result}"/>
|
||||
</Border>
|
||||
<Canvas Name="ConflictMap" DockPanel.Dock="Right" Width="13"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
ToolTip.Tip="{loc:Tr conflictResolver.conflictMap}"/>
|
||||
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
@@ -251,6 +252,7 @@ public partial class ConflictResolverView : Window
|
||||
private void PositionGutters()
|
||||
{
|
||||
ClearGutters();
|
||||
PopulateConflictMap();
|
||||
if (_vm?.ActiveFile is null) return;
|
||||
var tv = ResultEditor.TextArea.TextView;
|
||||
if (!tv.VisualLinesValid)
|
||||
@@ -298,6 +300,47 @@ public partial class ConflictResolverView : Window
|
||||
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);
|
||||
|
||||
// ── Synced vertical scroll across the three panes ─────────────────────────
|
||||
@@ -345,7 +388,7 @@ public partial class ConflictResolverView : Window
|
||||
private void ApplyGrammar(string? path)
|
||||
{
|
||||
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;
|
||||
var language = _registry.GetLanguageByExtension(ext);
|
||||
if (language is null) return;
|
||||
|
||||
Reference in New Issue
Block a user