231 lines
9.1 KiB
C#
231 lines
9.1 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.ComponentModel;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Ui.Localization;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
|
|
|
public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
|
{
|
|
public required string Id { get; init; }
|
|
public required string Title { get; init; }
|
|
public required string StatusText { get; init; }
|
|
public string? ParentTaskId { get; init; }
|
|
|
|
/// <summary>True when the parent is also a candidate and rendered directly above this row.</summary>
|
|
public bool IsChild { get; init; }
|
|
|
|
/// <summary>
|
|
/// True when <see cref="ParentTaskId"/> is set but the parent isn't a candidate itself
|
|
/// (e.g. already Done/Cancelled) — the row still renders top-level, flagged with a hint.
|
|
/// </summary>
|
|
public bool IsOrphanChild { get; init; }
|
|
|
|
[ObservableProperty] private bool _isSelected;
|
|
}
|
|
|
|
/// <summary>
|
|
/// "Let Claude handle it" task picker: lists the non-terminal tasks of a list (or all lists),
|
|
/// pre-ticks the actionable ones, and resolves <see cref="Result"/> with the ordered selected
|
|
/// task ids (null on cancel).
|
|
/// </summary>
|
|
public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private string _listId = "";
|
|
|
|
// Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade.
|
|
private Dictionary<string, List<MergeHelperTaskRowViewModel>> _childrenByParentId = new();
|
|
|
|
// Set while ReconcileTickAsync restores remembered ticks, so replaying a parent's state
|
|
// doesn't cascade over its children's own remembered state.
|
|
private bool _suppressCascade;
|
|
|
|
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
|
|
|
[ObservableProperty] private string _scopeLabel = "";
|
|
|
|
public bool HasTasks => Tasks.Count > 0;
|
|
public bool CanConfirm => Tasks.Any(t => t.IsSelected);
|
|
|
|
public TaskCompletionSource<IReadOnlyList<string>?> Result { get; } = new();
|
|
public Action? CloseAction { get; set; }
|
|
|
|
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
|
|
|
|
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user ticks
|
|
// through candidates), so refresh it on the same cadence as TasksIslandViewModel's tick
|
|
// instead of leaving it frozen at the moment it was opened.
|
|
_reconcileTimer.Elapsed += (_, _) =>
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
|
|
_reconcileTimer.Start();
|
|
}
|
|
|
|
public void Configure(string listId, string listName)
|
|
{
|
|
_listId = listId;
|
|
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
|
}
|
|
|
|
// LoadAsync rebuilds every row from scratch and re-applies IsTickedByDefault, which would wipe
|
|
// the user's ticks out from under them on every tick — in BOTH directions. Remembering only
|
|
// the ticked ids would silently re-tick a row the user deliberately unticked (Idle/Queued/
|
|
// WaitingForReview/Failed all default to ticked), so carry the full state of every row that
|
|
// already existed across the reload; only genuinely new rows get the default.
|
|
internal async Task ReconcileTickAsync()
|
|
{
|
|
if (string.IsNullOrEmpty(_listId)) return;
|
|
var previous = Tasks.ToDictionary(t => t.Id, t => t.IsSelected);
|
|
await LoadAsync();
|
|
|
|
// Restore without the parent→children cascade: it would overwrite a child's own remembered
|
|
// state with its parent's, undoing exactly the per-child choice being restored.
|
|
_suppressCascade = true;
|
|
try
|
|
{
|
|
foreach (var t in Tasks)
|
|
if (previous.TryGetValue(t.Id, out var wasSelected))
|
|
t.IsSelected = wasSelected;
|
|
}
|
|
finally
|
|
{
|
|
_suppressCascade = false;
|
|
}
|
|
|
|
OnPropertyChanged(nameof(CanConfirm));
|
|
}
|
|
|
|
public async Task LoadAsync(CancellationToken ct = default)
|
|
{
|
|
foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged;
|
|
Tasks.Clear();
|
|
_childrenByParentId = new();
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var candidates = await ctx.Tasks.AsNoTracking()
|
|
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
|
|
.Where(t => t.ListId == _listId)
|
|
// Manual tasks are reminders the user owns — never offer them to the handler.
|
|
.Where(t => !t.IsManual)
|
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
|
.Select(t => new { t.Id, t.Title, t.Status, t.ParentTaskId })
|
|
.ToListAsync(ct);
|
|
|
|
var candidateIds = candidates.Select(c => c.Id).ToHashSet();
|
|
bool ParentIsCandidate(string? parentId) =>
|
|
!string.IsNullOrEmpty(parentId) && candidateIds.Contains(parentId);
|
|
|
|
var childrenByParent = candidates
|
|
.Where(c => ParentIsCandidate(c.ParentTaskId))
|
|
.GroupBy(c => c.ParentTaskId!)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
// Hierarchy-ordered walk, same idea as TasksIslandViewModel.Regroup: top-level rows
|
|
// (including orphaned children whose parent isn't a candidate) interleaved with their
|
|
// visible children, preserving the SortOrder/CreatedAt order within each level.
|
|
var emitted = new HashSet<string>();
|
|
var ordered = new List<(string Id, string Title, TaskStatus Status, string? ParentTaskId)>();
|
|
void Emit(string id, string title, TaskStatus status, string? parentTaskId)
|
|
{
|
|
if (!emitted.Add(id)) return;
|
|
ordered.Add((id, title, status, parentTaskId));
|
|
if (childrenByParent.TryGetValue(id, out var kids))
|
|
foreach (var kid in kids)
|
|
Emit(kid.Id, kid.Title, kid.Status, kid.ParentTaskId);
|
|
}
|
|
foreach (var c in candidates.Where(c => !ParentIsCandidate(c.ParentTaskId)))
|
|
Emit(c.Id, c.Title, c.Status, c.ParentTaskId);
|
|
|
|
foreach (var c in ordered)
|
|
{
|
|
var isChild = ParentIsCandidate(c.ParentTaskId);
|
|
var row = new MergeHelperTaskRowViewModel
|
|
{
|
|
Id = c.Id,
|
|
Title = c.Title,
|
|
StatusText = c.Status.ToString(),
|
|
ParentTaskId = c.ParentTaskId,
|
|
IsChild = isChild,
|
|
IsOrphanChild = !isChild && !string.IsNullOrEmpty(c.ParentTaskId),
|
|
IsSelected = IsTickedByDefault(c.Status),
|
|
};
|
|
row.PropertyChanged += OnRowChanged;
|
|
Tasks.Add(row);
|
|
}
|
|
|
|
_childrenByParentId = Tasks
|
|
.Where(r => r.IsChild)
|
|
.GroupBy(r => r.ParentTaskId!)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
OnPropertyChanged(nameof(HasTasks));
|
|
OnPropertyChanged(nameof(CanConfirm));
|
|
}
|
|
|
|
// Actionable statuses are pre-ticked; Running/WaitingForChildren are listed but unticked
|
|
// (the helper would only poll them). Done/Cancelled never make it into the list.
|
|
internal static bool IsTickedByDefault(TaskStatus status) => status
|
|
is TaskStatus.Idle or TaskStatus.Queued or TaskStatus.WaitingForReview or TaskStatus.Failed;
|
|
|
|
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName != nameof(MergeHelperTaskRowViewModel.IsSelected)) return;
|
|
|
|
// Ticking/unticking a parent cascades to its children; a child can still be
|
|
// deselected on its own afterwards without affecting the parent's checkbox —
|
|
// ClaudeDo has no indeterminate/tri-state parent checkbox.
|
|
if (!_suppressCascade
|
|
&& sender is MergeHelperTaskRowViewModel row
|
|
&& _childrenByParentId.TryGetValue(row.Id, out var children))
|
|
{
|
|
foreach (var child in children) child.IsSelected = row.IsSelected;
|
|
}
|
|
|
|
OnPropertyChanged(nameof(CanConfirm));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SelectAll()
|
|
{
|
|
foreach (var t in Tasks) t.IsSelected = true;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SelectNone()
|
|
{
|
|
foreach (var t in Tasks) t.IsSelected = false;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Confirm()
|
|
{
|
|
StopReconcileTick();
|
|
Result.TrySetResult(Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToList());
|
|
CloseAction?.Invoke();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Cancel()
|
|
{
|
|
StopReconcileTick();
|
|
Result.TrySetResult(null);
|
|
CloseAction?.Invoke();
|
|
}
|
|
|
|
// Also called from the dialog's native-close fallback (WindowDialogService), which resolves
|
|
// Result without going through Confirm/Cancel. Idempotent — Timer.Stop()/Dispose() tolerate
|
|
// repeated calls.
|
|
internal void StopReconcileTick()
|
|
{
|
|
_reconcileTimer.Stop();
|
|
_reconcileTimer.Dispose();
|
|
}
|
|
}
|