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:
201
src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
Normal file
201
src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class TaskDetailViewModel : ViewModelBase
|
||||
{
|
||||
private readonly TaskRepository _taskRepo;
|
||||
private readonly WorktreeRepository _worktreeRepo;
|
||||
private readonly ListRepository _listRepo;
|
||||
private readonly GitService _git;
|
||||
private readonly WorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string? _description;
|
||||
[ObservableProperty] private string? _result;
|
||||
[ObservableProperty] private string? _logPath;
|
||||
[ObservableProperty] private string _statusText = "";
|
||||
|
||||
// Worktree
|
||||
[ObservableProperty] private bool _hasWorktree;
|
||||
[ObservableProperty] private string? _branchName;
|
||||
[ObservableProperty] private string? _diffStat;
|
||||
[ObservableProperty] private string? _worktreePath;
|
||||
[ObservableProperty] private string _worktreeState = "";
|
||||
|
||||
// Live stream
|
||||
public ObservableCollection<string> LiveLines { get; } = new();
|
||||
|
||||
private string? _taskId;
|
||||
private string? _listId;
|
||||
private const int MaxLiveLines = 500;
|
||||
|
||||
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo,
|
||||
ListRepository listRepo, GitService git, WorkerClient worker)
|
||||
{
|
||||
_taskRepo = taskRepo;
|
||||
_worktreeRepo = worktreeRepo;
|
||||
_listRepo = listRepo;
|
||||
_git = git;
|
||||
_worker = worker;
|
||||
|
||||
worker.TaskMessageEvent += OnTaskMessage;
|
||||
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
||||
worker.TaskUpdatedEvent += OnTaskUpdated;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(string taskId)
|
||||
{
|
||||
_taskId = taskId;
|
||||
LiveLines.Clear();
|
||||
|
||||
var task = await _taskRepo.GetByIdAsync(taskId);
|
||||
if (task is null) return;
|
||||
|
||||
_listId = task.ListId;
|
||||
Title = task.Title;
|
||||
Description = task.Description;
|
||||
Result = task.Result;
|
||||
LogPath = task.LogPath;
|
||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||
|
||||
await LoadWorktreeAsync(taskId);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_taskId = null;
|
||||
_listId = null;
|
||||
Title = "";
|
||||
Description = null;
|
||||
Result = null;
|
||||
LogPath = null;
|
||||
StatusText = "";
|
||||
HasWorktree = false;
|
||||
LiveLines.Clear();
|
||||
}
|
||||
|
||||
private async Task LoadWorktreeAsync(string taskId)
|
||||
{
|
||||
var wt = await _worktreeRepo.GetByTaskIdAsync(taskId);
|
||||
HasWorktree = wt is not null;
|
||||
if (wt is not null)
|
||||
{
|
||||
BranchName = wt.BranchName;
|
||||
DiffStat = wt.DiffStat;
|
||||
WorktreePath = wt.Path;
|
||||
WorktreeState = wt.State.ToString().ToLowerInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
BranchName = null;
|
||||
DiffStat = null;
|
||||
WorktreePath = null;
|
||||
WorktreeState = "";
|
||||
}
|
||||
OnPropertyChanged(nameof(CanWorktreeAction));
|
||||
}
|
||||
|
||||
public bool CanWorktreeAction => HasWorktree && WorktreeState == "active";
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenWorktree()
|
||||
{
|
||||
if (WorktreePath is null) return;
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = WorktreePath,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowDiff()
|
||||
{
|
||||
// TODO: open a proper diff viewer; for now open git diff in a console
|
||||
if (WorktreePath is null) return;
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/k git -C \"{WorktreePath}\" diff HEAD~1",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task MergeIntoMainAsync()
|
||||
{
|
||||
if (_taskId is null || _listId is null) return;
|
||||
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
|
||||
var list = await _listRepo.GetByIdAsync(_listId);
|
||||
if (wt is null || list?.WorkingDir is null) return;
|
||||
|
||||
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
|
||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
||||
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
|
||||
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
|
||||
await LoadWorktreeAsync(_taskId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task KeepAsBranchAsync()
|
||||
{
|
||||
if (_taskId is null || _listId is null) return;
|
||||
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
|
||||
var list = await _listRepo.GetByIdAsync(_listId);
|
||||
if (wt is null || list?.WorkingDir is null) return;
|
||||
|
||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
||||
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
|
||||
await LoadWorktreeAsync(_taskId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DiscardAsync()
|
||||
{
|
||||
if (_taskId is null || _listId is null) return;
|
||||
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
|
||||
var list = await _listRepo.GetByIdAsync(_listId);
|
||||
if (wt is null || list?.WorkingDir is null) return;
|
||||
|
||||
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
|
||||
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
|
||||
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
|
||||
await LoadWorktreeAsync(_taskId);
|
||||
}
|
||||
|
||||
private void OnTaskMessage(string taskId, string line)
|
||||
{
|
||||
if (taskId != _taskId) return;
|
||||
if (LiveLines.Count >= MaxLiveLines)
|
||||
LiveLines.RemoveAt(0);
|
||||
LiveLines.Add(line);
|
||||
}
|
||||
|
||||
private async void OnWorktreeUpdated(string taskId)
|
||||
{
|
||||
if (taskId != _taskId) return;
|
||||
await LoadWorktreeAsync(taskId);
|
||||
}
|
||||
|
||||
private async void OnTaskUpdated(string taskId)
|
||||
{
|
||||
if (taskId != _taskId) return;
|
||||
await LoadAsync(taskId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user