using System.Collections.ObjectModel; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Agent; using ClaudeDo.Ui.ViewModels.Modals.Settings; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Ui.ViewModels.Modals; // UI-only projection of TicketProjectDto for the ComboBox display text, plus a synthetic // Id=0 "none" entry (mirrors AgentConfigEditorViewModel's "(none)" AgentInfo placeholder). public sealed record TicketProjectOption(int Id, string Display); public sealed partial class ListSettingsModalViewModel : ViewModelBase { private readonly IWorkerClient _worker; private readonly IDbContextFactory _dbFactory; public string ListId { get; set; } = ""; // True after the list was deleted, so the caller reloads the list nav instead of refreshing the row. public bool Deleted { get; private set; } // Wired by the view to prompt yes/no before deleting and to surface a blocking-FK error. public Func>? ConfirmAsync { get; set; } public Func? ShowErrorAsync { get; set; } [ObservableProperty] private string _name = ""; [ObservableProperty] private string _workingDir = ""; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; // A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual). [ObservableProperty] private bool _isManual; // When true the project's .claudedo/ findings store is committed with the repo; when false it // is kept out of git via .git/info/exclude (see FindingsStore / ListEntity.FindingsTracked). [ObservableProperty] private bool _findingsTracked; // Optional post-merge verification command (build/test), run in WorkingDir after a merge // lands; a non-zero exit keeps the task out of Done instead of silently reporting merged. [ObservableProperty] private string _verifyCommand = ""; // When on, the queue picker holds back a queued task whose declared scope globs overlap a // running/awaiting-merge sibling in this list (ListConfigEntity.SerializeOnFileOverlap). [ObservableProperty] private bool _serializeOnFileOverlap; // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). [ObservableProperty] private bool _ticketsAvailable; [ObservableProperty] private TicketProjectOption? _selectedTicketProject; public ObservableCollection TicketProjects { get; } = new(); public ObservableCollection CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); // The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile), // scoped to this list (list → global inheritance). public AgentConfigEditorViewModel Agent { get; } public Action? CloseAction { get; set; } public ListSettingsModalViewModel(IWorkerClient worker, IDbContextFactory dbFactory) { _worker = worker; _dbFactory = dbFactory; Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List); } public async Task LoadAsync( string listId, string name, string? workingDir, string defaultCommitType, bool isManual = false, bool findingsTracked = false, CancellationToken ct = default) { ListId = listId; Name = name; IsManual = isManual; FindingsTracked = findingsTracked; WorkingDir = workingDir ?? ""; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType; await Agent.LoadForListAsync(listId, ct); var cfg = await _worker.GetListConfigAsync(listId); VerifyCommand = cfg?.VerifyCommand ?? ""; SerializeOnFileOverlap = cfg?.SerializeOnFileOverlap ?? false; var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled ? await _worker.GetTicketSettingsAsync() : null; TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl); TicketProjects.Clear(); if (TicketsAvailable) { TicketProjects.Add(new TicketProjectOption(0, Loc.T("vm.listSettings.ticketProjectNone"))); foreach (var p in await _worker.GetTicketProjectsAsync()) TicketProjects.Add(new TicketProjectOption(p.Id, $"{p.DepartmentName} / {p.Title}")); SelectedTicketProject = cfg?.TicketProjectId is int id ? TicketProjects.FirstOrDefault(t => t.Id == id) ?? TicketProjects[0] : TicketProjects[0]; } else { SelectedTicketProject = null; } } [RelayCommand] private async Task SaveAsync() { await _worker.UpdateListAsync(new UpdateListDto( ListId, string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name, string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir, DefaultCommitType, IsManual, FindingsTracked)); // Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone. // Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it. // Never null here, or an explicit unlink would silently fail to reach the worker. var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null; await Agent.SaveAsync( string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, SerializeOnFileOverlap, ticketProjectId); CloseAction?.Invoke(); } [RelayCommand] private async Task DeleteAsync() { var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name; if (ConfirmAsync is not null) { var ok = await ConfirmAsync($"Delete list \"{displayName}\" and all its tasks? This cannot be undone."); if (!ok) return; } try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var lists = new ListRepository(ctx); await lists.DeleteAsync(ListId); } catch (Exception ex) when ( (ex is Microsoft.Data.Sqlite.SqliteException || ex.InnerException is Microsoft.Data.Sqlite.SqliteException) && (ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)) { if (ShowErrorAsync is not null) await ShowErrorAsync("This list has planning sessions with child tasks. Discard those first, then delete the list."); return; } Deleted = true; CloseAction?.Invoke(); } [RelayCommand] private void Cancel() => CloseAction?.Invoke(); }