552 lines
20 KiB
C#
552 lines
20 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
|
|
|
public sealed partial class TasksIslandViewModel : ViewModelBase
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly IWorkerClient? _worker;
|
|
private readonly Dictionary<string, bool> _expandedState = new();
|
|
private ListNavItemViewModel? _currentList;
|
|
private CancellationTokenSource? _loadCts;
|
|
|
|
public event EventHandler? SelectionChanged;
|
|
public event EventHandler? FocusAddTaskRequested;
|
|
public event EventHandler? TasksChanged;
|
|
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
|
|
|
|
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
|
|
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
|
|
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
|
|
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
|
|
|
|
[ObservableProperty] private string _newTaskTitle = "";
|
|
[ObservableProperty] private TaskRowViewModel? _selectedTask;
|
|
[ObservableProperty] private string _headerTitle = "";
|
|
[ObservableProperty] private string _headerEyebrow = "";
|
|
[ObservableProperty] private string _subtitle = "";
|
|
[ObservableProperty] private string _statusPill = "";
|
|
[ObservableProperty] private bool _hasStatusPill;
|
|
[ObservableProperty] private bool _isShowingCompleted = true;
|
|
[ObservableProperty] private bool _hasOverdue;
|
|
[ObservableProperty] private bool _hasOpen;
|
|
[ObservableProperty] private bool _hasCompleted;
|
|
[ObservableProperty] private bool _showOpenLabel;
|
|
[ObservableProperty] private string _completedHeader = "COMPLETED";
|
|
|
|
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
|
|
|
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_worker = worker;
|
|
if (_worker is not null)
|
|
{
|
|
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
|
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
|
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
|
}
|
|
}
|
|
|
|
private void OnWorkerTaskMessage(string taskId, string line)
|
|
{
|
|
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
|
if (row is not null) row.LiveTail = line;
|
|
}
|
|
|
|
private async void OnWorkerTaskUpdated(string taskId)
|
|
{
|
|
var list = _currentList;
|
|
if (list is null) return;
|
|
|
|
try
|
|
{
|
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await db.Tasks
|
|
.Include(t => t.List)
|
|
.Include(t => t.Worktree)
|
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
|
|
|
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
|
|
|
if (entity is null)
|
|
{
|
|
if (existing is not null) Items.Remove(existing);
|
|
}
|
|
else
|
|
{
|
|
var matches = TaskMatchesList(entity, list);
|
|
if (existing is not null && matches) existing.UpdateFromEntity(entity);
|
|
else if (existing is not null) Items.Remove(existing);
|
|
else if (matches) { LoadForList(list); return; }
|
|
else return;
|
|
}
|
|
|
|
Regroup();
|
|
UpdateSubtitle();
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch
|
|
{
|
|
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
|
|
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
|
|
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
|
|
ListKind.Virtual when list.Id == "virtual:queued" => t.Status == TaskStatus.Queued,
|
|
ListKind.Virtual when list.Id == "virtual:running" => t.Status == TaskStatus.Running,
|
|
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active,
|
|
ListKind.User => $"user:{t.ListId}" == list.Id,
|
|
_ => false,
|
|
};
|
|
|
|
private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm)
|
|
HeaderTitle = vm.Name;
|
|
}
|
|
|
|
public void LoadForList(ListNavItemViewModel? list)
|
|
{
|
|
_loadCts?.Cancel();
|
|
_loadCts?.Dispose();
|
|
_loadCts = new CancellationTokenSource();
|
|
var ct = _loadCts.Token;
|
|
|
|
if (_currentList is not null)
|
|
_currentList.PropertyChanged -= OnCurrentListPropertyChanged;
|
|
_currentList = list;
|
|
if (_currentList is not null)
|
|
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
|
|
|
|
Items.Clear();
|
|
OverdueItems.Clear();
|
|
OpenItems.Clear();
|
|
CompletedItems.Clear();
|
|
HasOverdue = false;
|
|
HasOpen = false;
|
|
HasCompleted = false;
|
|
ShowOpenLabel = false;
|
|
if (list is null) return;
|
|
|
|
HeaderTitle = list.Name;
|
|
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
|
|
|
|
_ = LoadForListAsync(list, ct);
|
|
}
|
|
|
|
private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
|
var all = await db.Tasks
|
|
.Include(t => t.List)
|
|
.Include(t => t.Worktree)
|
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
|
.ToListAsync(ct);
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
static bool IsPlanningStatus(TaskStatus s) => s == TaskStatus.Planning || s == TaskStatus.Planned;
|
|
|
|
IEnumerable<TaskEntity> filtered = list.Kind switch
|
|
{
|
|
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
|
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
|
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
|
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t =>
|
|
(t.Status == TaskStatus.Queued && t.ParentTaskId == null) ||
|
|
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Queued))),
|
|
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t =>
|
|
(t.Status == TaskStatus.Running && t.ParentTaskId == null) ||
|
|
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
|
|
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
|
|
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
|
_ => Enumerable.Empty<TaskEntity>(),
|
|
};
|
|
|
|
var filteredList = filtered.ToList();
|
|
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
|
|
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
|
|
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
|
|
{
|
|
if (existingIds.Add(c.Id))
|
|
filteredList.Add(c);
|
|
}
|
|
|
|
foreach (var t in filteredList)
|
|
Items.Add(TaskRowViewModel.FromEntity(t));
|
|
|
|
Regroup();
|
|
UpdateSubtitle();
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
|
|
internal void Regroup()
|
|
{
|
|
OverdueItems.Clear();
|
|
OpenItems.Clear();
|
|
CompletedItems.Clear();
|
|
|
|
// Restore IsExpanded from saved state
|
|
foreach (var r in Items)
|
|
{
|
|
if (_expandedState.TryGetValue(r.Id, out var saved))
|
|
r.IsExpanded = saved;
|
|
}
|
|
|
|
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
|
|
// Items is already ordered by SortOrder from the DB query.
|
|
var topLevel = Items.Where(r => !r.IsChild);
|
|
var flat = new List<TaskRowViewModel>();
|
|
foreach (var parent in topLevel)
|
|
{
|
|
flat.Add(parent);
|
|
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
|
|
{
|
|
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
|
flat.AddRange(children);
|
|
}
|
|
}
|
|
|
|
var today = DateTime.Today;
|
|
foreach (var r in flat)
|
|
{
|
|
var underOpenPlanningParent = r.IsChild &&
|
|
flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);
|
|
|
|
if (r.Done && !underOpenPlanningParent)
|
|
CompletedItems.Add(r);
|
|
else if (r.ScheduledFor is { } d && d.Date < today)
|
|
OverdueItems.Add(r);
|
|
else
|
|
OpenItems.Add(r);
|
|
}
|
|
|
|
HasOverdue = OverdueItems.Count > 0;
|
|
HasOpen = OpenItems.Count > 0;
|
|
HasCompleted = CompletedItems.Count > 0;
|
|
ShowOpenLabel = HasOpen && HasOverdue;
|
|
CompletedHeader = $"COMPLETED · {CompletedItems.Count}";
|
|
}
|
|
|
|
private void UpdateSubtitle()
|
|
{
|
|
var now = DateTime.Now;
|
|
var open = Items.Count(i => !i.Done);
|
|
var running = Items.Count(i => i.Status == TaskStatus.Running);
|
|
var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null);
|
|
|
|
Subtitle = open == 1 ? "1 open task" : $"{open} open tasks";
|
|
|
|
if (running > 0 || review > 0)
|
|
{
|
|
StatusPill = $"{running} running · {review} review";
|
|
HasStatusPill = true;
|
|
}
|
|
else
|
|
{
|
|
StatusPill = "";
|
|
HasStatusPill = false;
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task AddAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
|
var listId = _currentList.Id["user:".Length..];
|
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
var maxSort = await db.Tasks
|
|
.Where(t => t.ListId == listId)
|
|
.Select(t => (int?)t.SortOrder)
|
|
.MaxAsync();
|
|
var entity = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
ListId = listId,
|
|
Title = NewTaskTitle.Trim(),
|
|
CreatedAt = DateTime.UtcNow,
|
|
SortOrder = (maxSort ?? -1) + 1,
|
|
};
|
|
db.Tasks.Add(entity);
|
|
await db.SaveChangesAsync();
|
|
var row = TaskRowViewModel.FromEntity(entity);
|
|
Items.Add(row);
|
|
Regroup();
|
|
NewTaskTitle = "";
|
|
UpdateSubtitle();
|
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public bool CanReorder => _currentList?.Kind == ListKind.User;
|
|
|
|
public void ClearDropHints()
|
|
{
|
|
foreach (var r in Items)
|
|
{
|
|
r.DropHintAbove = false;
|
|
r.DropHintBelow = false;
|
|
}
|
|
}
|
|
|
|
public void SetDropHint(TaskRowViewModel target, bool placeBelow)
|
|
{
|
|
foreach (var r in Items)
|
|
{
|
|
var isTarget = ReferenceEquals(r, target);
|
|
r.DropHintAbove = isTarget && !placeBelow;
|
|
r.DropHintBelow = isTarget && placeBelow;
|
|
}
|
|
}
|
|
|
|
public async Task ReorderAsync(TaskRowViewModel source, TaskRowViewModel target, bool placeBelow)
|
|
{
|
|
if (!CanReorder || _currentList is null) return;
|
|
if (source.IsRunning || target.IsRunning) return;
|
|
if (ReferenceEquals(source, target)) return;
|
|
|
|
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
|
|
MoveWithinCollection(Items, source, target, placeBelow);
|
|
|
|
// Apply the same move in whichever section the row lives in.
|
|
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
|
|
// that's determined by Done flag and ScheduledFor date, not drag-drop.
|
|
var sourceSection = SectionFor(source);
|
|
var targetSection = SectionFor(target);
|
|
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
|
|
MoveWithinCollection(sourceSection, source, target, placeBelow);
|
|
|
|
var listId = _currentList.Id["user:".Length..];
|
|
var orderedIds = Items.Select(i => i.Id).ToList();
|
|
|
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
var idSet = orderedIds.ToHashSet();
|
|
var entities = await db.Tasks
|
|
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
|
.ToListAsync();
|
|
for (int i = 0; i < orderedIds.Count; i++)
|
|
{
|
|
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
|
|
if (e is not null) e.SortOrder = i;
|
|
}
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static void MoveWithinCollection(
|
|
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
|
|
TaskRowViewModel source,
|
|
TaskRowViewModel target,
|
|
bool placeBelow)
|
|
{
|
|
var srcIdx = coll.IndexOf(source);
|
|
var tgtIdx = coll.IndexOf(target);
|
|
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
|
|
|
|
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
|
|
if (srcIdx < finalIdx) finalIdx--;
|
|
if (finalIdx < 0) finalIdx = 0;
|
|
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
|
|
if (finalIdx == srcIdx) return;
|
|
|
|
coll.Move(srcIdx, finalIdx);
|
|
}
|
|
|
|
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
|
{
|
|
if (OverdueItems.Contains(row)) return OverdueItems;
|
|
if (OpenItems.Contains(row)) return OpenItems;
|
|
if (CompletedItems.Contains(row)) return CompletedItems;
|
|
return null;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
|
{
|
|
row.Done = !row.Done;
|
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
|
if (entity != null)
|
|
{
|
|
entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Manual;
|
|
row.Status = entity.Status;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
Regroup();
|
|
UpdateSubtitle();
|
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ToggleStarAsync(TaskRowViewModel row)
|
|
{
|
|
row.IsStarred = !row.IsStarred;
|
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
|
if (entity != null)
|
|
{
|
|
entity.IsStarred = row.IsStarred;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null || row.IsRunning) return;
|
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
|
if (entity is null) return;
|
|
entity.Status = TaskStatus.Queued;
|
|
await db.SaveChangesAsync();
|
|
row.Status = TaskStatus.Queued;
|
|
if (_worker is not null)
|
|
{
|
|
try { await _worker.WakeQueueAsync(); } catch { }
|
|
}
|
|
Regroup();
|
|
UpdateSubtitle();
|
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null) return;
|
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
|
if (entity is null) return;
|
|
entity.Status = TaskStatus.Manual;
|
|
await db.SaveChangesAsync();
|
|
row.Status = TaskStatus.Manual;
|
|
Regroup();
|
|
UpdateSubtitle();
|
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
|
{
|
|
if (row is null) return;
|
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
|
if (entity is null) return;
|
|
entity.ScheduledFor = when;
|
|
await db.SaveChangesAsync();
|
|
row.ScheduledFor = when;
|
|
Regroup();
|
|
UpdateSubtitle();
|
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private Task ClearScheduleAsync(TaskRowViewModel? row) =>
|
|
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
|
|
|
|
[RelayCommand]
|
|
private void Select(TaskRowViewModel row) => SelectedTask = row;
|
|
|
|
[RelayCommand]
|
|
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
|
|
|
|
[RelayCommand]
|
|
private void Sort() { /* placeholder — UI-only */ }
|
|
|
|
public event EventHandler? OpenListSettingsRequested;
|
|
|
|
[RelayCommand]
|
|
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
|
|
|
|
[RelayCommand]
|
|
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null || row.Status != TaskStatus.Manual) return;
|
|
ForegroundHelper.AllowAny();
|
|
try { await _worker!.StartPlanningSessionAsync(row.Id); }
|
|
catch { }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null || !row.IsPlanningParent) return;
|
|
if (_worker is null) return;
|
|
try
|
|
{
|
|
var draftCount = await _worker.GetPendingDraftCountAsync(row.Id);
|
|
var modalVm = new UnfinishedPlanningModalViewModel
|
|
{
|
|
TaskTitle = row.Title,
|
|
DraftCount = draftCount,
|
|
};
|
|
|
|
if (ShowUnfinishedPlanningModal is null)
|
|
return;
|
|
await ShowUnfinishedPlanningModal(modalVm);
|
|
|
|
var choice = await modalVm.Result.Task;
|
|
|
|
switch (choice)
|
|
{
|
|
case UnfinishedPlanningModalResult.Resume:
|
|
ForegroundHelper.AllowAny();
|
|
await _worker.ResumePlanningSessionAsync(row.Id);
|
|
break;
|
|
case UnfinishedPlanningModalResult.FinalizeNow:
|
|
await _worker.FinalizePlanningSessionAsync(row.Id);
|
|
break;
|
|
case UnfinishedPlanningModalResult.Discard:
|
|
await _worker.DiscardPlanningSessionAsync(row.Id);
|
|
break;
|
|
case UnfinishedPlanningModalResult.Cancel:
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null) return;
|
|
try { await _worker!.DiscardPlanningSessionAsync(row.Id); }
|
|
catch { }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null) return;
|
|
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); }
|
|
catch { }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ToggleExpand(TaskRowViewModel? row)
|
|
{
|
|
if (row is null) return;
|
|
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded);
|
|
_expandedState[row.Id] = next;
|
|
row.IsExpanded = next;
|
|
Regroup();
|
|
}
|
|
|
|
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
|
{
|
|
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
|
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|