feat(ui): wire avalonia desktop ui to data and worker
App: build a ServiceProvider in Program.cs (AppSettings, SqliteConnectionFactory, all repositories, GitService, WorkerClient, all view-models), apply schema, then hand control to Avalonia. App.OnFrameworkInitializationCompleted resolves MainWindowViewModel from the container. Ui: - AppSettings POCO loaded from ~/.todo-app/ui.config.json (db path, hub url). - WorkerClient wraps HubConnection with auto-reconnect, exposes IsConnected and ActiveTasks plus C# events for TaskStarted/Finished/Message/Updated and WorktreeUpdated; all inbound events are marshalled to the UI thread. - ViewModels: MainWindow (lists CRUD via ListEditor dialog), TaskList (load by list, add/edit/delete, auto WakeQueue on agent+queued create), TaskItem (RunNow gated on connection + status), TaskDetail (description, result, live ndjson rolling buffer of 500 lines, worktree branch/diff with merge/keep/ discard via GitService), StatusBar, ListEditor, TaskEditor. - Views: 3-pane MainWindow (lists | tasks | detail) with GridSplitters, status bar, dialog windows for the editors. Status badges via StatusColorConverter. - Markdown rendering, folder picker, delete-confirmation, settings dialog and scroll-to-bottom on the live log are intentionally TODO -- functional scaffold only. Tests: also debounce the FIFO queue test (poll instead of Task.Delay(200)) so the assertion isn't racy when the suite runs alongside the slower git tests. 38 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
Normal file
218
src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.Views;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class TaskListViewModel : ViewModelBase
|
||||
{
|
||||
private readonly TaskRepository _taskRepo;
|
||||
private readonly TagRepository _tagRepo;
|
||||
private readonly ListRepository _listRepo;
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly Func<TaskEditorViewModel> _editorFactory;
|
||||
private readonly Action<string> _showMessage;
|
||||
|
||||
public ObservableCollection<TaskItemViewModel> Tasks { get; } = new();
|
||||
|
||||
[ObservableProperty] private TaskItemViewModel? _selectedTask;
|
||||
[ObservableProperty] private string? _currentListId;
|
||||
|
||||
public event Action<TaskItemViewModel?>? SelectedTaskChanged;
|
||||
|
||||
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
|
||||
SelectedTaskChanged?.Invoke(value);
|
||||
|
||||
public TaskListViewModel(TaskRepository taskRepo, TagRepository tagRepo,
|
||||
ListRepository listRepo, WorkerClient worker,
|
||||
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
|
||||
{
|
||||
_taskRepo = taskRepo;
|
||||
_tagRepo = tagRepo;
|
||||
_listRepo = listRepo;
|
||||
_worker = worker;
|
||||
_editorFactory = editorFactory;
|
||||
_showMessage = showMessage;
|
||||
|
||||
worker.TaskUpdatedEvent += OnTaskUpdated;
|
||||
worker.TaskFinishedEvent += (_, taskId, _, _) => OnTaskUpdated(taskId);
|
||||
}
|
||||
|
||||
public async Task LoadAsync(string? listId)
|
||||
{
|
||||
CurrentListId = listId;
|
||||
Tasks.Clear();
|
||||
SelectedTask = null;
|
||||
|
||||
if (listId is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var entities = await _taskRepo.GetByListAsync(listId);
|
||||
foreach (var e in entities)
|
||||
{
|
||||
var tags = await _taskRepo.GetEffectiveTagsAsync(e.Id);
|
||||
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_showMessage($"Error loading tasks: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddTask()
|
||||
{
|
||||
if (CurrentListId is null) return;
|
||||
|
||||
// Get list default commit type
|
||||
var list = await _listRepo.GetByIdAsync(CurrentListId);
|
||||
var defaultCommitType = list?.DefaultCommitType ?? "chore";
|
||||
|
||||
var editor = _editorFactory();
|
||||
editor.InitForCreate(CurrentListId, defaultCommitType);
|
||||
|
||||
var window = new TaskEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
_ = ShowDialogAsync(window);
|
||||
|
||||
var saved = await editor.ShowAndWaitAsync();
|
||||
if (saved is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _taskRepo.AddAsync(saved);
|
||||
|
||||
foreach (var tagName in editor.SelectedTagNames)
|
||||
{
|
||||
var tagId = await _tagRepo.GetOrCreateAsync(tagName);
|
||||
await _taskRepo.AddTagAsync(saved.Id, tagId);
|
||||
}
|
||||
|
||||
var tags = await _taskRepo.GetEffectiveTagsAsync(saved.Id);
|
||||
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected));
|
||||
|
||||
// Auto wake-queue if agent+queued
|
||||
if (saved.Status == TaskStatus.Queued &&
|
||||
tags.Any(t => t.Name == "agent"))
|
||||
{
|
||||
try { await _worker.WakeQueueAsync(); }
|
||||
catch { /* worker offline is fine */ }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_showMessage($"Error creating task: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditTask()
|
||||
{
|
||||
if (SelectedTask is null || CurrentListId is null) return;
|
||||
var entity = await _taskRepo.GetByIdAsync(SelectedTask.Id);
|
||||
if (entity is null) return;
|
||||
|
||||
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
|
||||
var editor = _editorFactory();
|
||||
editor.InitForEdit(entity, taskTags);
|
||||
|
||||
var window = new TaskEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
_ = ShowDialogAsync(window);
|
||||
|
||||
var saved = await editor.ShowAndWaitAsync();
|
||||
if (saved is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _taskRepo.UpdateAsync(saved);
|
||||
|
||||
var existingTags = await _taskRepo.GetTagsAsync(saved.Id);
|
||||
foreach (var old in existingTags)
|
||||
await _taskRepo.RemoveTagAsync(saved.Id, old.Id);
|
||||
foreach (var tagName in editor.SelectedTagNames)
|
||||
{
|
||||
var tagId = await _tagRepo.GetOrCreateAsync(tagName);
|
||||
await _taskRepo.AddTagAsync(saved.Id, tagId);
|
||||
}
|
||||
|
||||
var newTags = await _taskRepo.GetEffectiveTagsAsync(saved.Id);
|
||||
SelectedTask.Refresh(saved, newTags);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_showMessage($"Error updating task: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteTask()
|
||||
{
|
||||
if (SelectedTask is null) return;
|
||||
try
|
||||
{
|
||||
await _taskRepo.DeleteAsync(SelectedTask.Id);
|
||||
Tasks.Remove(SelectedTask);
|
||||
SelectedTask = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_showMessage($"Error deleting task: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshSingleAsync(string taskId)
|
||||
{
|
||||
var entity = await _taskRepo.GetByIdAsync(taskId);
|
||||
var existing = Tasks.FirstOrDefault(t => t.Id == taskId);
|
||||
if (entity is null)
|
||||
{
|
||||
if (existing is not null) Tasks.Remove(existing);
|
||||
return;
|
||||
}
|
||||
var tags = await _taskRepo.GetEffectiveTagsAsync(taskId);
|
||||
if (existing is not null)
|
||||
existing.Refresh(entity, tags);
|
||||
}
|
||||
|
||||
private async Task RunNowAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _worker.RunNowAsync(taskId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_showMessage($"RunNow failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnTaskUpdated(string taskId)
|
||||
{
|
||||
if (CurrentListId is null) return;
|
||||
await RefreshSingleAsync(taskId);
|
||||
}
|
||||
|
||||
private static async Task ShowDialogAsync(Window dialog)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
|
||||
&& desktop.MainWindow is not null)
|
||||
{
|
||||
await dialog.ShowDialog(desktop.MainWindow);
|
||||
}
|
||||
else
|
||||
{
|
||||
dialog.Show();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user