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:
72
src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
Normal file
72
src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class ListEditorViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string? _workingDir;
|
||||
[ObservableProperty] private string _defaultCommitType = "chore";
|
||||
[ObservableProperty] private string _windowTitle = "New List";
|
||||
|
||||
private string? _editId;
|
||||
private DateTime _createdAt;
|
||||
private TaskCompletionSource<ListEntity?> _tcs = new();
|
||||
|
||||
public event Action? RequestClose;
|
||||
|
||||
public static string[] CommitTypes { get; } =
|
||||
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
|
||||
|
||||
public void InitForCreate()
|
||||
{
|
||||
_editId = null;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
WindowTitle = "New List";
|
||||
}
|
||||
|
||||
public void InitForEdit(ListEntity entity)
|
||||
{
|
||||
_editId = entity.Id;
|
||||
_createdAt = entity.CreatedAt;
|
||||
Name = entity.Name;
|
||||
WorkingDir = entity.WorkingDir;
|
||||
DefaultCommitType = entity.DefaultCommitType;
|
||||
WindowTitle = $"Edit List: {entity.Name}";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Name)) return;
|
||||
var entity = new ListEntity
|
||||
{
|
||||
Id = _editId ?? Guid.NewGuid().ToString(),
|
||||
Name = Name.Trim(),
|
||||
CreatedAt = _createdAt,
|
||||
WorkingDir = string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir.Trim(),
|
||||
DefaultCommitType = DefaultCommitType,
|
||||
};
|
||||
_tcs.TrySetResult(entity);
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
_tcs.TrySetResult(null);
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the view to await the editor result.
|
||||
/// Returns the entity to persist or null if cancelled.
|
||||
/// </summary>
|
||||
public Task<ListEntity?> ShowAndWaitAsync()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<ListEntity?>();
|
||||
return _tcs.Task;
|
||||
}
|
||||
}
|
||||
30
src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs
Normal file
30
src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class ListItemViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _name;
|
||||
[ObservableProperty] private string? _workingDir;
|
||||
[ObservableProperty] private string _defaultCommitType;
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public ListItemViewModel(ListEntity entity)
|
||||
{
|
||||
Id = entity.Id;
|
||||
_name = entity.Name;
|
||||
_workingDir = entity.WorkingDir;
|
||||
_defaultCommitType = entity.DefaultCommitType;
|
||||
}
|
||||
|
||||
public ListEntity ToEntity() => new()
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
CreatedAt = DateTime.MinValue, // not used for update
|
||||
WorkingDir = string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||
DefaultCommitType = DefaultCommitType,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,159 @@
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
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;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
public string Greeting { get; } = "Welcome to Avalonia!";
|
||||
private readonly ListRepository _listRepo;
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly Func<ListEditorViewModel> _listEditorFactory;
|
||||
|
||||
public ObservableCollection<ListItemViewModel> Lists { get; } = new();
|
||||
|
||||
[ObservableProperty] private ListItemViewModel? _selectedList;
|
||||
|
||||
public TaskListViewModel TaskList { get; }
|
||||
public TaskDetailViewModel TaskDetail { get; }
|
||||
public StatusBarViewModel StatusBar { get; }
|
||||
|
||||
public MainWindowViewModel(
|
||||
ListRepository listRepo,
|
||||
WorkerClient worker,
|
||||
TaskListViewModel taskList,
|
||||
TaskDetailViewModel taskDetail,
|
||||
StatusBarViewModel statusBar,
|
||||
Func<ListEditorViewModel> listEditorFactory)
|
||||
{
|
||||
_listRepo = listRepo;
|
||||
_worker = worker;
|
||||
_listEditorFactory = listEditorFactory;
|
||||
TaskList = taskList;
|
||||
TaskDetail = taskDetail;
|
||||
StatusBar = statusBar;
|
||||
|
||||
TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lists = await _listRepo.GetAllAsync();
|
||||
foreach (var l in lists)
|
||||
Lists.Add(new ListItemViewModel(l));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBar.ShowMessage($"Error loading lists: {ex.Message}");
|
||||
}
|
||||
|
||||
_ = _worker.StartAsync();
|
||||
}
|
||||
|
||||
partial void OnSelectedListChanged(ListItemViewModel? value)
|
||||
{
|
||||
_ = TaskList.LoadAsync(value?.Id);
|
||||
TaskDetail.Clear();
|
||||
}
|
||||
|
||||
private async void OnSelectedTaskChanged(TaskItemViewModel? task)
|
||||
{
|
||||
if (task is null)
|
||||
TaskDetail.Clear();
|
||||
else
|
||||
await TaskDetail.LoadAsync(task.Id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddList()
|
||||
{
|
||||
var editor = _listEditorFactory();
|
||||
editor.InitForCreate();
|
||||
|
||||
var window = new ListEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
_ = ShowDialogAsync(window);
|
||||
|
||||
var entity = await editor.ShowAndWaitAsync();
|
||||
if (entity is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _listRepo.AddAsync(entity);
|
||||
Lists.Add(new ListItemViewModel(entity));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBar.ShowMessage($"Error creating list: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task EditList()
|
||||
{
|
||||
if (SelectedList is null) return;
|
||||
var existing = await _listRepo.GetByIdAsync(SelectedList.Id);
|
||||
if (existing is null) return;
|
||||
|
||||
var editor = _listEditorFactory();
|
||||
editor.InitForEdit(existing);
|
||||
|
||||
var window = new ListEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
_ = ShowDialogAsync(window);
|
||||
|
||||
var entity = await editor.ShowAndWaitAsync();
|
||||
if (entity is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _listRepo.UpdateAsync(entity);
|
||||
SelectedList.Name = entity.Name;
|
||||
SelectedList.WorkingDir = entity.WorkingDir;
|
||||
SelectedList.DefaultCommitType = entity.DefaultCommitType;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBar.ShowMessage($"Error updating list: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteList()
|
||||
{
|
||||
if (SelectedList is null) return;
|
||||
// TODO: confirmation dialog
|
||||
try
|
||||
{
|
||||
await _listRepo.DeleteAsync(SelectedList.Id);
|
||||
Lists.Remove(SelectedList);
|
||||
SelectedList = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBar.ShowMessage($"Error deleting list: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs
Normal file
52
src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Specialized;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class StatusBarViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _connectionStatus = "Offline";
|
||||
[ObservableProperty] private string _activeTasksSummary = "";
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public StatusBarViewModel(WorkerClient worker)
|
||||
{
|
||||
_worker = worker;
|
||||
|
||||
worker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||
{
|
||||
ConnectionStatus = worker.IsConnected ? "Online" : "Offline";
|
||||
}
|
||||
};
|
||||
|
||||
worker.ActiveTasks.CollectionChanged += OnActiveTasksChanged;
|
||||
RefreshActiveSummary();
|
||||
}
|
||||
|
||||
private void OnActiveTasksChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||
RefreshActiveSummary();
|
||||
|
||||
private void RefreshActiveSummary()
|
||||
{
|
||||
if (_worker.ActiveTasks.Count == 0)
|
||||
{
|
||||
ActiveTasksSummary = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = _worker.ActiveTasks
|
||||
.Select(t => $"{t.Slot}: {Shorten(t.TaskId)}")
|
||||
.ToList();
|
||||
ActiveTasksSummary = string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
private static string Shorten(string id) =>
|
||||
id.Length > 8 ? id[..8] : id;
|
||||
|
||||
public void ShowMessage(string msg) => StatusMessage = msg;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
97
src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs
Normal file
97
src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class TaskEditorViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private string? _description;
|
||||
[ObservableProperty] private string _commitType = "chore";
|
||||
[ObservableProperty] private string _statusChoice = "manual";
|
||||
[ObservableProperty] private string _tagsInput = "";
|
||||
[ObservableProperty] private string _windowTitle = "New Task";
|
||||
|
||||
private string? _editId;
|
||||
private string _listId = "";
|
||||
private DateTime _createdAt;
|
||||
private TaskCompletionSource<TaskEntity?> _tcs = new();
|
||||
|
||||
public event Action? RequestClose;
|
||||
|
||||
public static string[] CommitTypes { get; } =
|
||||
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
|
||||
|
||||
public static string[] StatusChoices { get; } =
|
||||
["manual", "queued"];
|
||||
|
||||
public IReadOnlyList<string> SelectedTagNames =>
|
||||
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
public void InitForCreate(string listId, string defaultCommitType = "chore")
|
||||
{
|
||||
_editId = null;
|
||||
_listId = listId;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
CommitType = defaultCommitType;
|
||||
WindowTitle = "New Task";
|
||||
}
|
||||
|
||||
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
|
||||
{
|
||||
_editId = entity.Id;
|
||||
_listId = entity.ListId;
|
||||
_createdAt = entity.CreatedAt;
|
||||
Title = entity.Title;
|
||||
Description = entity.Description;
|
||||
CommitType = entity.CommitType;
|
||||
StatusChoice = entity.Status switch
|
||||
{
|
||||
TaskStatus.Manual => "manual",
|
||||
TaskStatus.Queued => "queued",
|
||||
_ => entity.Status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
|
||||
WindowTitle = $"Edit Task: {entity.Title}";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Save()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Title)) return;
|
||||
var status = StatusChoice switch
|
||||
{
|
||||
"queued" => TaskStatus.Queued,
|
||||
_ => TaskStatus.Manual,
|
||||
};
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = _editId ?? Guid.NewGuid().ToString(),
|
||||
ListId = _listId,
|
||||
Title = Title.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
|
||||
Status = status,
|
||||
CommitType = CommitType,
|
||||
CreatedAt = _createdAt,
|
||||
};
|
||||
_tcs.TrySetResult(entity);
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
_tcs.TrySetResult(null);
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
|
||||
public Task<TaskEntity?> ShowAndWaitAsync()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<TaskEntity?>();
|
||||
return _tcs.Task;
|
||||
}
|
||||
}
|
||||
61
src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs
Normal file
61
src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
public partial class TaskItemViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _title;
|
||||
[ObservableProperty] private string _statusText;
|
||||
[ObservableProperty] private string _tagsText;
|
||||
[ObservableProperty] private string _commitType;
|
||||
[ObservableProperty] private string? _description;
|
||||
[ObservableProperty] private TaskStatus _status;
|
||||
|
||||
public string Id { get; }
|
||||
public string ListId { get; }
|
||||
public TaskEntity Entity { get; private set; }
|
||||
|
||||
private readonly Func<string, Task>? _runNow;
|
||||
private readonly Func<bool> _canRunNow;
|
||||
|
||||
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
|
||||
Func<string, Task>? runNow, Func<bool> canRunNow)
|
||||
{
|
||||
Entity = entity;
|
||||
Id = entity.Id;
|
||||
ListId = entity.ListId;
|
||||
_title = entity.Title;
|
||||
_status = entity.Status;
|
||||
_statusText = entity.Status.ToString().ToLowerInvariant();
|
||||
_tagsText = string.Join(", ", tags.Select(t => t.Name));
|
||||
_commitType = entity.CommitType;
|
||||
_description = entity.Description;
|
||||
_runNow = runNow;
|
||||
_canRunNow = canRunNow;
|
||||
}
|
||||
|
||||
public void Refresh(TaskEntity entity, IReadOnlyList<TagEntity> tags)
|
||||
{
|
||||
Entity = entity;
|
||||
Title = entity.Title;
|
||||
Status = entity.Status;
|
||||
StatusText = entity.Status.ToString().ToLowerInvariant();
|
||||
TagsText = string.Join(", ", tags.Select(t => t.Name));
|
||||
CommitType = entity.CommitType;
|
||||
Description = entity.Description;
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
||||
private async Task RunNowAsync()
|
||||
{
|
||||
if (_runNow is not null)
|
||||
await _runNow(Id);
|
||||
}
|
||||
|
||||
private bool CanRunNow() =>
|
||||
_canRunNow() && Status == TaskStatus.Queued;
|
||||
}
|
||||
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