diff --git a/src/ClaudeDo.App/App.axaml.cs b/src/ClaudeDo.App/App.axaml.cs index c655858..f50916f 100644 --- a/src/ClaudeDo.App/App.axaml.cs +++ b/src/ClaudeDo.App/App.axaml.cs @@ -1,16 +1,16 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; -using Avalonia.Data.Core.Plugins; -using System.Linq; using Avalonia.Markup.Xaml; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.Views; +using Microsoft.Extensions.DependencyInjection; namespace ClaudeDo.App; public partial class App : Application { + public static ServiceProvider Services { get; set; } = null!; + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -22,10 +22,10 @@ public partial class App : Application { desktop.MainWindow = new MainWindow { - DataContext = new MainWindowViewModel(), + DataContext = Services.GetRequiredService(), }; } base.OnFrameworkInitializationCompleted(); } -} \ No newline at end of file +} diff --git a/src/ClaudeDo.App/ClaudeDo.App.csproj b/src/ClaudeDo.App/ClaudeDo.App.csproj index 852bd5f..cf1d4b6 100644 --- a/src/ClaudeDo.App/ClaudeDo.App.csproj +++ b/src/ClaudeDo.App/ClaudeDo.App.csproj @@ -22,6 +22,7 @@ All + diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 039b475..e5842db 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -1,18 +1,31 @@ -using Avalonia; +using Avalonia; +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Ui; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels; +using Microsoft.Extensions.DependencyInjection; using System; namespace ClaudeDo.App; sealed class Program { - // Initialization code. Don't use any Avalonia, third-party APIs or any - // SynchronizationContext-reliant code before AppMain is called: things aren't initialized - // yet and stuff might break. [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) + { + var services = BuildServices(); + App.Services = services; + + // Ensure DB schema exists + var factory = services.GetRequiredService(); + SchemaInitializer.Apply(factory); + + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } - // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() @@ -21,4 +34,56 @@ sealed class Program #endif .WithInterFont() .LogToTrace(); + + private static ServiceProvider BuildServices() + { + var settings = AppSettings.Load(); + var dbPath = Paths.Expand(settings.DbPath); + + var sc = new ServiceCollection(); + + // Infrastructure + sc.AddSingleton(settings); + sc.AddSingleton(new SqliteConnectionFactory(dbPath)); + + // Repositories + sc.AddSingleton(); + sc.AddSingleton(); + sc.AddSingleton(); + sc.AddSingleton(); + + // Services + sc.AddSingleton(); + sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService().SignalRUrl)); + + // ViewModels + sc.AddTransient(); + sc.AddTransient(); + sc.AddSingleton(); + sc.AddSingleton(); + sc.AddSingleton(sp => + { + var taskRepo = sp.GetRequiredService(); + var tagRepo = sp.GetRequiredService(); + var listRepo = sp.GetRequiredService(); + var worker = sp.GetRequiredService(); + var statusBar = sp.GetRequiredService(); + return new TaskListViewModel( + taskRepo, tagRepo, listRepo, worker, + () => sp.GetRequiredService(), + msg => statusBar.ShowMessage(msg)); + }); + sc.AddSingleton(sp => + { + return new MainWindowViewModel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + () => sp.GetRequiredService()); + }); + + return sc.BuildServiceProvider(); + } } diff --git a/src/ClaudeDo.Ui/AppSettings.cs b/src/ClaudeDo.Ui/AppSettings.cs new file mode 100644 index 0000000..73b6b11 --- /dev/null +++ b/src/ClaudeDo.Ui/AppSettings.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using ClaudeDo.Data; + +namespace ClaudeDo.Ui; + +public sealed class AppSettings +{ + public string DbPath { get; set; } = "~/.todo-app/todo.db"; + public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub"; + + private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json"); + + public static AppSettings Load() + { + try + { + if (File.Exists(ConfigPath)) + { + var json = File.ReadAllText(ConfigPath); + return JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new(); + } + } + catch + { + // Fall through to defaults + } + return new(); + } +} diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index fae1598..5f5053c 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -8,6 +8,7 @@ + diff --git a/src/ClaudeDo.Ui/Converters/StatusColorConverter.cs b/src/ClaudeDo.Ui/Converters/StatusColorConverter.cs new file mode 100644 index 0000000..e128ff6 --- /dev/null +++ b/src/ClaudeDo.Ui/Converters/StatusColorConverter.cs @@ -0,0 +1,41 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ClaudeDo.Ui.Converters; + +public class StatusColorConverter : IValueConverter +{ + public static StatusColorConverter Instance { get; } = new(); + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var status = value?.ToString()?.ToLowerInvariant(); + return status switch + { + "queued" => Brushes.DodgerBlue, + "running" => Brushes.Orange, + "done" => Brushes.Green, + "failed" => Brushes.Red, + "manual" => Brushes.Gray, + _ => Brushes.Transparent, + }; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class ConnectionColorConverter : IValueConverter +{ + public static ConnectionColorConverter Instance { get; } = new(); + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var text = value?.ToString(); + return text == "Online" ? Brushes.Green : Brushes.Red; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs new file mode 100644 index 0000000..b301859 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -0,0 +1,154 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.AspNetCore.SignalR.Client; + +namespace ClaudeDo.Ui.Services; + +public record ActiveTask(string Slot, string TaskId, DateTime StartedAt); + +public partial class WorkerClient : ObservableObject, IAsyncDisposable +{ + private readonly HubConnection _hub; + + [ObservableProperty] + private bool _isConnected; + + public ObservableCollection ActiveTasks { get; } = new(); + + public event Action? TaskStartedEvent; + public event Action? TaskFinishedEvent; + public event Action? TaskMessageEvent; + public event Action? TaskUpdatedEvent; + public event Action? WorktreeUpdatedEvent; + + public WorkerClient(string signalRUrl) + { + _hub = new HubConnectionBuilder() + .WithUrl(signalRUrl) + .WithAutomaticReconnect() + .Build(); + + _hub.Reconnected += async _ => + { + Dispatcher.UIThread.Post(() => IsConnected = true); + await SeedActiveTasksAsync(); + }; + + _hub.Reconnecting += _ => + { + Dispatcher.UIThread.Post(() => IsConnected = false); + return Task.CompletedTask; + }; + + _hub.Closed += _ => + { + Dispatcher.UIThread.Post(() => + { + IsConnected = false; + ActiveTasks.Clear(); + }); + return Task.CompletedTask; + }; + + _hub.On("TaskStarted", (slot, taskId, startedAt) => + { + Dispatcher.UIThread.Post(() => + { + ActiveTasks.Add(new ActiveTask(slot, taskId, startedAt)); + TaskStartedEvent?.Invoke(slot, taskId, startedAt); + }); + }); + + _hub.On("TaskFinished", (slot, taskId, status, finishedAt) => + { + Dispatcher.UIThread.Post(() => + { + var existing = ActiveTasks.FirstOrDefault(t => t.TaskId == taskId); + if (existing is not null) + ActiveTasks.Remove(existing); + TaskFinishedEvent?.Invoke(slot, taskId, status, finishedAt); + }); + }); + + _hub.On("TaskMessage", (taskId, line) => + { + Dispatcher.UIThread.Post(() => TaskMessageEvent?.Invoke(taskId, line)); + }); + + _hub.On("TaskUpdated", taskId => + { + Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId)); + }); + + _hub.On("WorktreeUpdated", taskId => + { + Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId)); + }); + } + + public async Task StartAsync() + { + try + { + await _hub.StartAsync(); + Dispatcher.UIThread.Post(() => IsConnected = true); + await SeedActiveTasksAsync(); + } + catch + { + Dispatcher.UIThread.Post(() => IsConnected = false); + } + } + + public async Task StopAsync() + { + try { await _hub.StopAsync(); } catch { /* swallow */ } + } + + public async Task RunNowAsync(string taskId) + { + await _hub.InvokeAsync("RunNow", taskId); + } + + public async Task CancelTaskAsync(string taskId) + { + await _hub.InvokeAsync("CancelTask", taskId); + } + + public async Task WakeQueueAsync() + { + await _hub.InvokeAsync("WakeQueue"); + } + + private async Task SeedActiveTasksAsync() + { + try + { + var active = await _hub.InvokeAsync>("GetActive"); + Dispatcher.UIThread.Post(() => + { + ActiveTasks.Clear(); + foreach (var a in active) + ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt)); + }); + } + catch + { + // Worker might not support GetActive yet + } + } + + public async ValueTask DisposeAsync() + { + await _hub.DisposeAsync(); + } + + // DTO for deserializing the GetActive response + private sealed class ActiveTaskDto + { + public string Slot { get; set; } = ""; + public string TaskId { get; set; } = ""; + public DateTime StartedAt { get; set; } + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs new file mode 100644 index 0000000..638f68a --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs @@ -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 _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(); + } + + /// + /// Called by the view to await the editor result. + /// Returns the entity to persist or null if cancelled. + /// + public Task ShowAndWaitAsync() + { + _tcs = new TaskCompletionSource(); + return _tcs.Task; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs new file mode 100644 index 0000000..889a94c --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/ListItemViewModel.cs @@ -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, + }; +} diff --git a/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs index f753981..dc853dd 100644 --- a/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs @@ -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 _listEditorFactory; + + public ObservableCollection 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 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(); + } + } } diff --git a/src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs b/src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs new file mode 100644 index 0000000..8b1dc00 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs @@ -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; +} diff --git a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs new file mode 100644 index 0000000..ce09b6d --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs @@ -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 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); + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs new file mode 100644 index 0000000..81ee756 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs @@ -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 _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 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 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 ShowAndWaitAsync() + { + _tcs = new TaskCompletionSource(); + return _tcs.Task; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs new file mode 100644 index 0000000..bf4c3d7 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs @@ -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? _runNow; + private readonly Func _canRunNow; + + public TaskItemViewModel(TaskEntity entity, IReadOnlyList tags, + Func? runNow, Func 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 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; +} diff --git a/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs new file mode 100644 index 0000000..9b89662 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs @@ -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 _editorFactory; + private readonly Action _showMessage; + + public ObservableCollection Tasks { get; } = new(); + + [ObservableProperty] private TaskItemViewModel? _selectedTask; + [ObservableProperty] private string? _currentListId; + + public event Action? SelectedTaskChanged; + + partial void OnSelectedTaskChanged(TaskItemViewModel? value) => + SelectedTaskChanged?.Invoke(value); + + public TaskListViewModel(TaskRepository taskRepo, TagRepository tagRepo, + ListRepository listRepo, WorkerClient worker, + Func editorFactory, Action 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(); + } + } +} diff --git a/src/ClaudeDo.Ui/Views/ListEditorView.axaml b/src/ClaudeDo.Ui/Views/ListEditorView.axaml new file mode 100644 index 0000000..c9cb587 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/ListEditorView.axaml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + +