838 lines
36 KiB
Markdown
838 lines
36 KiB
Markdown
# Layer B — Multi-Worktree Merge Cockpit Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Turn the worktrees-overview modal into a batch-merge cockpit (multi-select N worktrees → one target branch → "Merge all" with skip-and-continue conflict collection), and migrate `WorktreeModalView`'s bespoke inline diff onto the canonical `DiffLinesView`.
|
||
|
||
**Architecture:** The cockpit VM keeps depending on the concrete `WorkerClient` (the overview/cleanup/state methods live only on `WorkerClient`, not `IWorkerClient`). The batch loop is extracted into a delegate-driven method `MergeSelectedAsync(Func<...> mergeFn)` so it is unit-testable with a fake merge function and a never-connected `WorkerClient`. Clean merges (`Status=="merged"`) update the row; conflicts (`Status=="conflict"`, which `MergeTaskAsync` already auto-aborts) are collected into a `ConflictRows` list whose rows expose a `Resolve` button wired to an inert `RequestConflictResolution(taskId, targetBranch)` seam. The diff migration replaces the right-pane `ItemsControl` in `WorktreeModalView` with `DiffLinesView`, feeding it `DiffLineViewModel`s produced by `UnifiedDiffParser`, and deletes the now-dead `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind`.
|
||
|
||
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, xUnit. Build UI with `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`; run `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`.
|
||
|
||
**Frozen contracts reused (do NOT modify):**
|
||
- `WorkerClient.MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) -> Task<MergeResultDto>`
|
||
- `WorkerClient.GetMergeTargetsAsync(string taskId) -> Task<MergeTargetsDto?>` (`MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches)`)
|
||
- `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)` — `Status` is `"merged" | "conflict" | "blocked" | <other>`
|
||
- `WorkerClient.GetWorktreesOverviewAsync`, `CleanupFinishedWorktreesAsync`, `SetWorktreeStateAsync`, `ForceRemoveWorktreeAsync`
|
||
- `GitService.GetFileDiffAsync(worktreePath, baseCommit?, relativePath)` returns a `git diff` blob including the `diff --git` header (so `UnifiedDiffParser.Parse` handles it)
|
||
- `DiffLinesView` (`Lines` styled property, `IEnumerable?`), `DiffLineViewModel`, `DiffFileViewModel`, `UnifiedDiffParser.Parse` / `.Flatten`
|
||
|
||
**Do NOT touch:** any worker-side files (`WorkerHub`, `TaskMergeService`, `GitService`), `IWorkerClient.cs` / `WorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, and do not create any `ConflictResolver` UI or reference any `ConflictResolver` type.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` — **modify.** Add `BatchMergeOutcome` enum; add `IsChecked`/`MergeOutcome` (+ derived) to the row VM; add `MergeTargets`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `ConflictRows`, the `RequestConflictResolution` seam, `MergeSelectedAsync`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, target loading, and per-row check subscription. Keep all existing context-menu commands/wiring intact.
|
||
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` — **modify.** Add a per-row checkbox + outcome badge, a target `ComboBox` + "Merge all" button + progress text in the toolbar, and a "Needs resolution" panel listing `ConflictRows` with `Resolve` buttons.
|
||
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs` — **modify.** Replace `SelectedFileDiffLines` element type with `DiffLineViewModel` produced via `UnifiedDiffParser`; delete `WorktreeDiffLineKind` and `WorktreeDiffLineViewModel`.
|
||
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml` — **modify.** Replace the right-pane `ItemsControl` with `ctl:DiffLinesView`; drop the `DiffLineKindToBrushConverter` resource.
|
||
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — **modify.** Add new `modals.worktreesOverview.*` and `vm.worktreesOverview.*` keys (keep parity).
|
||
- `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` — **create.** Unit tests for `MergeSelectedAsync` skip-and-continue, conflict collection, progress, selection gating, and the resolve seam.
|
||
|
||
No `IWorkerClient` change → no test-fake updates needed.
|
||
|
||
---
|
||
|
||
## Task 1: Row-level batch state (outcome enum + row VM fields)
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`:
|
||
|
||
```csharp
|
||
using ClaudeDo.Data.Models;
|
||
using ClaudeDo.Ui.ViewModels.Modals;
|
||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||
using Xunit;
|
||
|
||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||
|
||
public class WorktreesOverviewBatchMergeTests
|
||
{
|
||
private static WorktreeOverviewRowViewModel ActiveRow(string id) => new()
|
||
{
|
||
TaskId = id,
|
||
TaskTitle = $"Task {id}",
|
||
TaskStatus = TaskStatus.WaitingForReview,
|
||
State = WorktreeState.Active,
|
||
};
|
||
|
||
[Fact]
|
||
public void Row_outcome_helpers_reflect_state()
|
||
{
|
||
var row = ActiveRow("a");
|
||
Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome);
|
||
Assert.False(row.IsConflict);
|
||
|
||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||
Assert.True(row.IsConflict);
|
||
|
||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||
Assert.False(row.IsConflict);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||
Expected: FAIL — `BatchMergeOutcome` and `MergeOutcome`/`IsConflict` do not exist (compile error).
|
||
|
||
- [ ] **Step 3: Add the enum and row fields**
|
||
|
||
In `WorktreesOverviewModalViewModel.cs`, add the enum just above `WorktreeOverviewRowViewModel`:
|
||
|
||
```csharp
|
||
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
|
||
```
|
||
|
||
Inside `WorktreeOverviewRowViewModel`, add after the existing `_isSelected` field:
|
||
|
||
```csharp
|
||
[ObservableProperty] private bool _isChecked;
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(IsConflict))]
|
||
[NotifyPropertyChangedFor(nameof(HasOutcome))]
|
||
private BatchMergeOutcome _mergeOutcome;
|
||
|
||
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
|
||
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||
Expected: PASS (1 test).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||
git commit -m "feat(ui): add batch-merge row state to worktrees cockpit VM"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Batch orchestration (`MergeSelectedAsync` skip-and-continue)
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `WorktreesOverviewBatchMergeTests.cs`. The helper builds a VM with a never-connected `WorkerClient` (the loop never touches it) and seeds `Rows` directly:
|
||
|
||
```csharp
|
||
private static WorktreesOverviewModalViewModel NewVm() =>
|
||
new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!);
|
||
|
||
private static MergeResultDto Merged() => new("merged", System.Array.Empty<string>(), null);
|
||
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
|
||
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "blocked");
|
||
|
||
[Fact]
|
||
public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows()
|
||
{
|
||
var vm = NewVm();
|
||
var a = ActiveRow("a"); a.IsChecked = true;
|
||
var b = ActiveRow("b"); b.IsChecked = false; // unchecked -> skipped
|
||
var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; // not active -> skipped
|
||
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||
vm.SelectedTarget = "main";
|
||
|
||
var seen = new System.Collections.Generic.List<string>();
|
||
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||
{
|
||
seen.Add(id);
|
||
Assert.Equal("main", target);
|
||
Assert.False(remove); // removeWorktree must be false
|
||
return System.Threading.Tasks.Task.FromResult(Merged());
|
||
});
|
||
|
||
Assert.Equal(new[] { "a" }, seen);
|
||
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||
Assert.False(a.IsChecked); // cleared after merge
|
||
}
|
||
|
||
[Fact]
|
||
public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it()
|
||
{
|
||
var vm = NewVm();
|
||
var a = ActiveRow("a"); a.IsChecked = true;
|
||
var b = ActiveRow("b"); b.IsChecked = true;
|
||
var c = ActiveRow("c"); c.IsChecked = true;
|
||
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||
vm.SelectedTarget = "main";
|
||
|
||
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||
System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged()));
|
||
|
||
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||
Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome);
|
||
Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); // continued past the conflict
|
||
Assert.Contains(b, vm.ConflictRows);
|
||
Assert.Single(vm.ConflictRows);
|
||
}
|
||
|
||
[Fact]
|
||
public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes()
|
||
{
|
||
var vm = NewVm();
|
||
var a = ActiveRow("a"); a.IsChecked = true;
|
||
var b = ActiveRow("b"); b.IsChecked = true;
|
||
vm.Rows.Add(a); vm.Rows.Add(b);
|
||
vm.SelectedTarget = "main";
|
||
|
||
await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a"
|
||
? System.Threading.Tasks.Task.FromResult(Blocked())
|
||
: throw new System.InvalidOperationException("boom"));
|
||
|
||
Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome);
|
||
Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome);
|
||
Assert.Empty(vm.ConflictRows);
|
||
Assert.False(vm.IsMerging);
|
||
}
|
||
|
||
[Fact]
|
||
public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target()
|
||
{
|
||
var vm = NewVm();
|
||
var a = ActiveRow("a"); a.IsChecked = true;
|
||
vm.Rows.Add(a);
|
||
vm.SelectedTarget = null;
|
||
|
||
var called = false;
|
||
await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); });
|
||
|
||
Assert.False(called);
|
||
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||
Expected: FAIL — `MergeSelectedAsync`, `ConflictRows`, `IsMerging`, `SelectedTarget` do not exist (compile error).
|
||
|
||
- [ ] **Step 3: Implement the orchestration + cockpit fields**
|
||
|
||
In `WorktreesOverviewModalViewModel.cs`, add these `using`s if missing: `using ClaudeDo.Ui.Services;` (already present). Add fields/properties to `WorktreesOverviewModalViewModel` (after the existing `_selectedRow` field):
|
||
|
||
```csharp
|
||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
|
||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
|
||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
|
||
[ObservableProperty] private string? _batchProgress;
|
||
|
||
public ObservableCollection<string> MergeTargets { get; } = new();
|
||
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
||
|
||
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
||
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||
```
|
||
|
||
Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method:
|
||
|
||
```csharp
|
||
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
||
|
||
public async Task MergeSelectedAsync(
|
||
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
||
CancellationToken ct = default)
|
||
{
|
||
var target = SelectedTarget;
|
||
if (string.IsNullOrWhiteSpace(target)) return;
|
||
|
||
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
|
||
if (selected.Count == 0) return;
|
||
|
||
IsMerging = true;
|
||
ConflictRows.Clear();
|
||
var done = 0;
|
||
try
|
||
{
|
||
foreach (var row in selected)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
row.MergeOutcome = BatchMergeOutcome.Merging;
|
||
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
||
|
||
MergeResultDto result;
|
||
try
|
||
{
|
||
result = await mergeFn(row.TaskId, target!, false,
|
||
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
||
}
|
||
catch
|
||
{
|
||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||
continue;
|
||
}
|
||
|
||
switch (result.Status)
|
||
{
|
||
case "merged":
|
||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||
row.State = WorktreeState.Merged;
|
||
row.IsChecked = false;
|
||
break;
|
||
case "conflict":
|
||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||
ConflictRows.Add(row);
|
||
break;
|
||
case "blocked":
|
||
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
||
break;
|
||
default:
|
||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||
break;
|
||
}
|
||
}
|
||
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
||
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
||
}
|
||
finally
|
||
{
|
||
IsMerging = false;
|
||
}
|
||
}
|
||
```
|
||
|
||
> Note: `Loc.T` keys are added in Task 5; they resolve to the key name (harmless) until then, so tests pass now.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||
Expected: PASS (5 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||
git commit -m "feat(ui): add skip-and-continue batch merge orchestration"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Selection tracking, target loading, commands + resolve seam
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `WorktreesOverviewBatchMergeTests.cs`:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void SelectedCount_tracks_checked_active_rows()
|
||
{
|
||
var vm = NewVm();
|
||
var a = ActiveRow("a");
|
||
var b = ActiveRow("b");
|
||
var merged = ActiveRow("c"); merged.State = WorktreeState.Merged;
|
||
vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged);
|
||
|
||
Assert.Equal(0, vm.SelectedCount);
|
||
a.IsChecked = true;
|
||
Assert.Equal(1, vm.SelectedCount);
|
||
b.IsChecked = true;
|
||
merged.IsChecked = true; // not active -> not counted
|
||
Assert.Equal(2, vm.SelectedCount);
|
||
a.IsChecked = false;
|
||
Assert.Equal(1, vm.SelectedCount);
|
||
}
|
||
|
||
[Fact]
|
||
public void ResolveConflict_invokes_seam_with_task_and_target()
|
||
{
|
||
var vm = NewVm();
|
||
vm.SelectedTarget = "release";
|
||
var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||
|
||
(string Task, string Target)? captured = null;
|
||
vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; };
|
||
|
||
vm.ResolveConflictCommand.Execute(row);
|
||
|
||
Assert.Equal(("x", "release"), captured);
|
||
}
|
||
|
||
[Fact]
|
||
public void MergeAll_canExecute_requires_target_selection_and_idle()
|
||
{
|
||
var vm = NewVm();
|
||
var a = ActiveRow("a");
|
||
vm.AddRowForTest(a);
|
||
|
||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // no selection, no target
|
||
a.IsChecked = true;
|
||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // still no target
|
||
vm.SelectedTarget = "main";
|
||
Assert.True(vm.MergeAllCommand.CanExecute(null));
|
||
vm.IsMerging = true;
|
||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // busy
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||
Expected: FAIL — `AddRowForTest`, `ResolveConflictCommand`, `MergeAllCommand` do not exist (compile error).
|
||
|
||
- [ ] **Step 3: Implement subscription, commands, target loading**
|
||
|
||
In `WorktreesOverviewModalViewModel.cs`:
|
||
|
||
(a) Add a row-hook that recomputes `SelectedCount` when a row's `IsChecked` changes, and a test seam to add a hooked row. Add these methods to the class:
|
||
|
||
```csharp
|
||
private void HookRow(WorktreeOverviewRowViewModel row)
|
||
{
|
||
row.PropertyChanged += (_, e) =>
|
||
{
|
||
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
|
||
or nameof(WorktreeOverviewRowViewModel.State))
|
||
RecomputeSelected();
|
||
};
|
||
}
|
||
|
||
private void RecomputeSelected() =>
|
||
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
|
||
|
||
// Test seam: adds a row to the flat list with selection tracking wired up.
|
||
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
|
||
{
|
||
HookRow(row);
|
||
Rows.Add(row);
|
||
}
|
||
```
|
||
|
||
(b) In `LoadAsync`, call `HookRow(row)` everywhere a row is added. Replace the two add sites:
|
||
|
||
In the grouped branch, change `foreach (var row in grp) group.Rows.Add(row);` to:
|
||
|
||
```csharp
|
||
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
||
```
|
||
|
||
In the flat branch, change `foreach (var row in ordered) Rows.Add(row);` to:
|
||
|
||
```csharp
|
||
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||
```
|
||
|
||
Also, at the start of `LoadAsync` after `IsBusy = true;`, reset batch UI state and (re)load merge targets at the end of the `try`:
|
||
|
||
After `Rows.Clear(); Groups.Clear();` add:
|
||
|
||
```csharp
|
||
ConflictRows.Clear();
|
||
SelectedCount = 0;
|
||
BatchProgress = null;
|
||
```
|
||
|
||
At the very end of the `try` block (after the if/else that fills rows/groups) add:
|
||
|
||
```csharp
|
||
await LoadMergeTargetsAsync();
|
||
```
|
||
|
||
(c) Add target loading. The branch list is repo-level, so query it from the first active row:
|
||
|
||
```csharp
|
||
private async Task LoadMergeTargetsAsync()
|
||
{
|
||
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
|
||
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
|
||
try
|
||
{
|
||
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
|
||
MergeTargets.Clear();
|
||
if (targets is null) { SelectedTarget = null; return; }
|
||
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
|
||
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
|
||
? targets.DefaultBranch
|
||
: MergeTargets.FirstOrDefault();
|
||
}
|
||
catch { MergeTargets.Clear(); SelectedTarget = null; }
|
||
}
|
||
```
|
||
|
||
(d) Add the commands:
|
||
|
||
```csharp
|
||
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
||
|
||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
||
|
||
[RelayCommand]
|
||
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||
{
|
||
if (row is null) return;
|
||
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void ToggleSelectAll()
|
||
{
|
||
var actives = AllRows.Where(r => r.IsActive).ToList();
|
||
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
|
||
foreach (var r in actives) r.IsChecked = !allChecked;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||
Expected: PASS (8 tests total in this file).
|
||
|
||
- [ ] **Step 5: Build the app project to confirm the VM compiles against generated commands**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Expected: Build succeeded.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||
git commit -m "feat(ui): wire batch selection, target loading and resolve seam"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Cockpit view — checkboxes, target picker, Merge all, conflicts panel
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||
|
||
This task is AXAML only (no logic) → no new unit test; flag for visual verification.
|
||
|
||
- [ ] **Step 1: Add the batch toolbar controls**
|
||
|
||
In `WorktreesOverviewModalView.axaml`, replace the toolbar `StackPanel` (currently containing Refresh, Cleanup finished, StatusMessage) with one that adds select-all, the target picker, the Merge-all button and progress text. Replace the inner `<StackPanel Orientation="Horizontal" Spacing="8">...</StackPanel>` of the toolbar `Border` with:
|
||
|
||
```xml
|
||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
|
||
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
|
||
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||
<ComboBox MinWidth="160"
|
||
ItemsSource="{Binding MergeTargets}"
|
||
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
|
||
<Button Classes="btn accent"
|
||
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
|
||
Command="{Binding MergeAllCommand}"/>
|
||
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
|
||
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||
Foreground="{DynamicResource TextDimBrush}"/>
|
||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||
Foreground="{DynamicResource TextDimBrush}"/>
|
||
</StackPanel>
|
||
```
|
||
|
||
- [ ] **Step 2: Add a checkbox + outcome badge to the row template**
|
||
|
||
In the `WorktreeRowTemplate` `DataTemplate`, change the row `Grid` to add a leading checkbox column and a trailing outcome column. Replace the `<Grid ColumnDefinitions="*,90,80,80">...</Grid>` (the whole grid, lines for Task/State/Diff/Age) with:
|
||
|
||
```xml
|
||
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
||
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||
IsEnabled="{Binding IsActive}"
|
||
IsVisible="{Binding IsActive}"/>
|
||
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
|
||
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
||
<TextBlock Classes="meta" Text="•"
|
||
IsVisible="{Binding !PathExistsOnDisk}"/>
|
||
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
|
||
IsVisible="{Binding !PathExistsOnDisk}"
|
||
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
||
</StackPanel>
|
||
</StackPanel>
|
||
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
||
Text="{Binding MergeOutcome}"
|
||
IsVisible="{Binding HasOutcome}"/>
|
||
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
||
HorizontalAlignment="Center"/>
|
||
</Border>
|
||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||
</Grid>
|
||
```
|
||
|
||
Then update the column-header `Grid` (the one with `ColumnDefinitions="*,90,80,80"` near the ScrollViewer top) to match the new column layout:
|
||
|
||
```xml
|
||
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
||
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
||
</Grid>
|
||
```
|
||
|
||
- [ ] **Step 3: Add the "Needs resolution" panel**
|
||
|
||
Inside the content `ScrollViewer`'s root `StackPanel`, at the very top (before the column-header `Grid`), add a conflicts panel that only shows when there are conflicts:
|
||
|
||
```xml
|
||
<Border IsVisible="{Binding ConflictRows.Count}"
|
||
Background="{DynamicResource ErrorTintBrush}"
|
||
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
|
||
<StackPanel Spacing="6">
|
||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
|
||
<ItemsControl ItemsSource="{Binding ConflictRows}">
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
||
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||
Text="{Binding TaskTitle}"/>
|
||
<Button Grid.Column="1" Classes="btn"
|
||
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
||
CommandParameter="{Binding}"/>
|
||
</Grid>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
</StackPanel>
|
||
</Border>
|
||
```
|
||
|
||
> `IsVisible="{Binding ConflictRows.Count}"` uses Avalonia's int→bool coercion (0 = false). If the build flags this, change to a value converter already present, but int→bool is supported.
|
||
|
||
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Expected: Build succeeded (compiled bindings resolve against the new VM members).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
|
||
git commit -m "feat(ui): batch-merge cockpit view with checkboxes and conflicts panel"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Localization keys (en + de parity)
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
||
|
||
- [ ] **Step 1: Add the new keys to `en.json`**
|
||
|
||
Under `modals.worktreesOverview`, add:
|
||
|
||
```json
|
||
"columnOutcome": "RESULT",
|
||
"selectAll": "Select all",
|
||
"targetLabel": "Target",
|
||
"mergeAll": "Merge all",
|
||
"needsResolution": "NEEDS RESOLUTION",
|
||
"resolve": "Resolve"
|
||
```
|
||
|
||
Under `vm.worktreesOverview`, add:
|
||
|
||
```json
|
||
"batchProgress": "Merging {0}/{1}…",
|
||
"batchDone": "Merged {0}, {1} need resolution."
|
||
```
|
||
|
||
- [ ] **Step 2: Add the matching keys to `de.json`**
|
||
|
||
Under `modals.worktreesOverview`:
|
||
|
||
```json
|
||
"columnOutcome": "ERGEBNIS",
|
||
"selectAll": "Alle auswählen",
|
||
"targetLabel": "Ziel",
|
||
"mergeAll": "Alle mergen",
|
||
"needsResolution": "ZU LÖSEN",
|
||
"resolve": "Lösen"
|
||
```
|
||
|
||
Under `vm.worktreesOverview`:
|
||
|
||
```json
|
||
"batchProgress": "Merge {0}/{1}…",
|
||
"batchDone": "{0} gemergt, {1} zu lösen."
|
||
```
|
||
|
||
- [ ] **Step 3: Run the localization parity test**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||
Expected: PASS (en/de key parity holds).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
||
git commit -m "feat(i18n): add batch-merge cockpit strings (en/de)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Migrate `WorktreeModalView` diff onto `DiffLinesView`
|
||
|
||
**Files:**
|
||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`
|
||
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`
|
||
|
||
- [ ] **Step 1: Switch the VM to the canonical diff model**
|
||
|
||
In `WorktreeModalViewModel.cs`:
|
||
|
||
(a) Delete the now-dead types at the top of the file:
|
||
|
||
```csharp
|
||
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
|
||
|
||
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
|
||
{
|
||
public required string Text { get; init; }
|
||
public required WorktreeDiffLineKind Kind { get; init; }
|
||
}
|
||
```
|
||
|
||
(b) Change the collection declaration from:
|
||
|
||
```csharp
|
||
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||
```
|
||
|
||
to:
|
||
|
||
```csharp
|
||
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||
```
|
||
|
||
(c) Replace the body of `LoadFileDiffAsync` (the `foreach (var line in diff.Split('\n'))` block) so it parses via `UnifiedDiffParser`. The method becomes:
|
||
|
||
```csharp
|
||
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
|
||
{
|
||
SelectedFileDiffLines.Clear();
|
||
|
||
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
|
||
return;
|
||
|
||
string diff;
|
||
try
|
||
{
|
||
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
|
||
}
|
||
catch
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
||
SelectedFileDiffLines.Add(line);
|
||
}
|
||
```
|
||
|
||
(`DiffLineViewModel`, `DiffFileViewModel`, and `UnifiedDiffParser` are all in the same `ClaudeDo.Ui.ViewModels.Modals` namespace, so no new `using` is required.)
|
||
|
||
- [ ] **Step 2: Build to confirm the VM compiles and nothing else referenced the deleted types**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Expected: Build succeeded. (If a compile error names `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind` outside this file or the view, that reference must be migrated too — there should be none besides `WorktreeModalView.axaml`, handled next.)
|
||
|
||
- [ ] **Step 3: Swap the view's inline diff for `DiffLinesView`**
|
||
|
||
In `WorktreeModalView.axaml`:
|
||
|
||
(a) Remove the now-unused converter resource. Delete:
|
||
|
||
```xml
|
||
<Window.Resources>
|
||
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
||
</Window.Resources>
|
||
```
|
||
|
||
(b) Replace the right-pane `ScrollViewer`'s `ItemsControl` (the `SelectableTextBlock` template bound to `SelectedFileDiffLines`) with the canonical control. Replace:
|
||
|
||
```xml
|
||
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
|
||
<SelectableTextBlock Text="{Binding Text}"
|
||
FontFamily="{DynamicResource MonoFont}"
|
||
FontSize="{StaticResource FontSizeMono}"
|
||
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
|
||
TextWrapping="NoWrap"/>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
```
|
||
|
||
with:
|
||
|
||
```xml
|
||
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
|
||
```
|
||
|
||
(The `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` namespace is already declared at the top of this file.)
|
||
|
||
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Expected: Build succeeded.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml
|
||
git commit -m "refactor(ui): render worktree modal diff via canonical DiffLinesView"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Full build + test sweep
|
||
|
||
**Files:** none (verification only).
|
||
|
||
- [ ] **Step 1: Build the whole app**
|
||
|
||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||
Expected: Build succeeded, 0 errors.
|
||
|
||
- [ ] **Step 2: Run the UI + localization test projects**
|
||
|
||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||
Then: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||
Expected: PASS (all green, including the 8 new batch-merge tests).
|
||
|
||
- [ ] **Step 3: Flag visual-verification gaps**
|
||
|
||
The cockpit toolbar/checkbox/conflicts-panel layout and the migrated `WorktreeModalView` diff rendering are AXAML changes that cannot be verified headlessly. Report to the user that these need a visual pass (run the app, open the worktrees overview, select several worktrees, pick a target, "Merge all", and open a worktree diff).
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
- **Spec coverage:** batch-merge cockpit (Tasks 1–4), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 3–4), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 3–4), `WorktreeModalView` diff migration to `DiffLinesView` (Task 6), no worker files touched, no `IWorkerClient` change, locales in parity (Task 5). ✔
|
||
- **No ConflictResolver reference:** the seam is a bare `Func<string,string,Task>?`; no Layer C type is named. ✔
|
||
- **Type consistency:** `BatchMergeOutcome`, `MergeOutcome`, `IsConflict`, `HasOutcome`, `MergeSelectedAsync`, `ConflictRows`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `RequestConflictResolution`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, `AddRowForTest`, `AllRows` are used consistently across tasks. ✔
|