Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs
T

526 lines
23 KiB
C#

using System.Collections.ObjectModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Converters;
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 Material.Icons;
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<ClaudeDoDbContext> _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; }
// False when the modal closed via Cancel or the window chrome — CreateListAsync uses this to
// roll the pre-created row back instead of leaving an untitled empty list behind.
public bool Saved { get; private set; }
// Wired by the view to prompt yes/no before deleting and to surface a blocking-FK error.
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
public Func<string, Task>? ShowErrorAsync { get; set; }
// Mirrors UsageMonitorModalViewModel.ErrorReported — the caller wires this into FlashFooterError.
public event Action<string>? ErrorReported;
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _workingDir = "";
// Recomputed whenever WorkingDir changes (typing or Browse) — RepoScanner.IsRepo is the same
// check RepoImport uses, so a folder either scans in there or warns here, never neither.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string _workingDirWarning = "";
[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 = "";
// Per-list opt-in for PowerUse. A handler run on this list only goes unattended (no questions,
// harvests new work, longer handoff chain) when this AND the global switch in Settings →
// Execution are both on. Off by default — the global switch alone must never change a list's behaviour.
[ObservableProperty] private bool _powerUseAllowed;
// Basis neuer Worktrees und Default-Merge-Ziel; leer = ausgecheckter Branch.
[ObservableProperty] private string _baseBranch = "";
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
private bool _ticketsAvailable;
/// <summary>The dropdown needs a configured ticket system *and* a list — folders link to nothing.</summary>
public bool ShowTicketProject => TicketsAvailable && !IsFolder;
// ---- Folder mode ----
// A folder is a sidebar grouping, not a list: it carries only a name and an icon, so every
// other field/section collapses. Reached two ways — the "create a folder instead" toggle in
// the create flow (FolderId still null), and editing an existing folder (FolderId set).
// There is no list <-> folder conversion: the toggle is offered on create only.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HeaderTitle))]
[NotifyPropertyChangedFor(nameof(DeleteLabel))]
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
private bool _isFolder;
/// <summary>True only while creating — an existing list can't become a folder or back.</summary>
[ObservableProperty] private bool _canToggleFolder;
private string? _folderId;
// Resolved rather than a {loc:Tr} key because the text depends on IsFolder.
public string HeaderTitle => Loc.T(IsFolder ? "modals.listSettings.folderTitle" : "modals.listSettings.title");
public string DeleteLabel => Loc.T(IsFolder ? "modals.listSettings.deleteFolder" : "modals.listSettings.deleteList");
[ObservableProperty] private TicketProjectOption? _selectedTicketProject;
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
// Gates Save/Delete while the core fields (name/agent/verify command) are still loading, so a
// save can't land before they're populated and clobber the list_config row with blanks. Ticket
// projects load separately (see LoadTicketProjectsAsync) and are NOT covered by this — Save's
// own tri-state handles that case instead.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
private bool _isLoading;
// Set when the core load (name/agent/verify command) threw — keeps Save gated even after
// IsLoading resets to false in LoadAsync's finally, so a failed load can't clobber the
// stored config with blank fields.
private bool _loadFailed;
// True once GetTicketProjectsAsync has completed successfully for the current load — Save
// only writes an explicit TicketProjectId when this is true, so a slow/unreachable ticket
// system leaves the stored link untouched instead of wiping it to "none".
private bool _ticketProjectsLoaded;
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
public const string DefaultIcon = "Dot";
/// <summary>Picker swatches: "" (default) followed by the accent-aware palette keys.</summary>
public static readonly string[] SwatchOptions = ["", .. AccentPresets.SwatchKeys];
/// <summary>Stored colour: "" (default), a swatch key, or a literal "#RRGGBB". Shared by lists and folders.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsCustomColor))]
private string _selectedColor = "";
/// <summary>True when <see cref="SelectedColor"/> is a custom hex rather than a swatch.</summary>
public bool IsCustomColor => SelectedColor.StartsWith('#');
// Separate from SelectedColor: the ListBox pushes null when a custom hex (not in SwatchOptions) is current.
[ObservableProperty] private string? _selectedSwatch = "";
/// <summary>Bound to the custom ColorPicker; writes back as "#RRGGBB".</summary>
[ObservableProperty] private Avalonia.Media.Color _customColor = Avalonia.Media.Color.Parse(AccentPresets.Default.Accent);
private bool _syncingColor;
partial void OnSelectedColorChanged(string value)
{
_syncingColor = true;
SelectedSwatch = SwatchOptions.Contains(value) ? value : null;
if (IsCustomColor && Avalonia.Media.Color.TryParse(value, out var c)) CustomColor = c;
_syncingColor = false;
}
partial void OnSelectedSwatchChanged(string? value)
{
if (!_syncingColor && value is not null) SelectedColor = value;
}
partial void OnCustomColorChanged(Avalonia.Media.Color value)
{
if (!_syncingColor) SelectedColor = $"#{value.R:X2}{value.G:X2}{value.B:X2}";
}
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
public const string DefaultFolderIcon = "Folder";
// Curated picker set, grouped by theme so the grid reads in rows. Each legacy key is offered as its
// MDI replacement (IconKeyConverter.LegacyKinds); "Dot" and "Folder" stay bare because they are the
// default-icon constants the sidebar compares against.
public static readonly string[] IconOptions = new[]
{
// general
"Dot", "Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
// dev
"Code", "Terminal", "Database", "Server", "Chip", "Bolt", "Wand", "Flask", "Shield",
// status / flow
"Activity", "Eye", "Check", "Warning", "Chart", "Rocket", "Bulb", "Broom", "AgentSuggested", "Settings",
// things
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
}.Select(k => k is DefaultIcon or DefaultFolderIcon ? k : IconKeyConverter.MdiPrefix + IconKeyConverter.LegacyKinds[k]).ToArray();
/// <summary>Cap on search hits so the picker's WrapPanel stays responsive.</summary>
public const int MaxIconResults = 200;
// One entry per distinct MDI icon; the enum carries aliases, all of which stay searchable.
private static readonly Lazy<(string Key, string Names)[]> MdiIndex = new(() =>
Enum.GetNames<MaterialIconKind>()
.GroupBy(Enum.Parse<MaterialIconKind>)
.Select(g => (IconKeyConverter.MdiPrefix + g.First(), string.Join(' ', g)))
.ToArray());
/// <summary>Picker items: the curated set, or MDI search hits once the search has 2+ characters.</summary>
[ObservableProperty] private IReadOnlyList<string> _icons = IconOptions;
/// <summary>Free-text filter over the MDI icon names (English).</summary>
[ObservableProperty] private string _iconSearch = "";
/// <summary>True when the search hit more than <see cref="MaxIconResults"/> icons.</summary>
[ObservableProperty] private bool _iconResultsTruncated;
// Separate from Icon: swapping Icons makes the ListBox push null here, which must not wipe the choice.
[ObservableProperty] private string? _selectedIconOption = DefaultIcon;
/// <summary>True for a curated key, a legacy bare key still stored in the DB, or an existing "mdi:" kind.</summary>
public static bool IsKnownIcon(string? key) =>
key is not null && (IconOptions.Contains(key)
|| IconKeyConverter.LegacyKinds.ContainsKey(key)
|| (key.StartsWith(IconKeyConverter.MdiPrefix, StringComparison.Ordinal)
&& Enum.TryParse<MaterialIconKind>(key[IconKeyConverter.MdiPrefix.Length..], out _)));
partial void OnIconSearchChanged(string value)
{
var q = value.Trim();
if (q.Length < 2)
{
IconResultsTruncated = false;
// Keep a chosen MDI icon visible (and selected) in the curated view.
Icons = IconOptions.Contains(Icon) ? IconOptions : [Icon, .. IconOptions];
}
else
{
var hits = MdiIndex.Value
.Where(e => e.Names.Contains(q, StringComparison.OrdinalIgnoreCase))
.Select(e => e.Key)
.Take(MaxIconResults + 1)
.ToList();
IconResultsTruncated = hits.Count > MaxIconResults;
Icons = hits.Take(MaxIconResults).ToList();
}
SelectedIconOption = Icons.Contains(Icon) ? Icon : null;
}
partial void OnSelectedIconOptionChanged(string? value)
{
if (value is not null) Icon = value;
}
partial void OnIconChanged(string value)
{
if (!Icons.Contains(value)) OnIconSearchChanged(IconSearch);
else SelectedIconOption = value;
}
// 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<ClaudeDoDbContext> dbFactory)
{
_worker = worker;
_dbFactory = dbFactory;
Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List);
}
// Fires the load without blocking the caller, so the modal window can be shown right away
// instead of appearing only after seven serial hub round-trips (one of them a 10s-timeout
// ticket-system HTTP call) have finished — same pattern as UsageMonitorModalViewModel.BeginLoad.
public void BeginLoad(
string listId,
string name,
string? workingDir,
string defaultCommitType,
bool isManual = false,
bool findingsTracked = false,
string? icon = null,
bool canToggleFolder = false,
string? color = null)
{
CanToggleFolder = canToggleFolder;
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon, color);
}
/// <summary>
/// Opens the modal on an existing folder: name + icon only, no list config to load and
/// nothing to fetch from the worker, so this needs no async counterpart.
/// </summary>
public void BeginLoadFolder(string folderId, string name, string? icon, string? color = null)
{
SelectedColor = color ?? "";
_folderId = folderId;
IsFolder = true;
CanToggleFolder = false;
Name = name;
Icon = IsKnownIcon(icon) ? icon! : DefaultFolderIcon;
}
public async Task LoadAsync(
string listId,
string name,
string? workingDir,
string defaultCommitType,
bool isManual = false,
bool findingsTracked = false,
string? icon = null,
string? color = null,
CancellationToken ct = default)
{
ListConfigDto? cfg = null;
IsLoading = true;
_loadFailed = false;
try
{
ListId = listId;
Name = name;
IsManual = isManual;
FindingsTracked = findingsTracked;
// An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead.
Icon = IsKnownIcon(icon) ? icon! : DefaultIcon;
SelectedColor = color ?? "";
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
await Agent.LoadForListAsync(listId, ct);
cfg = Agent.LastLoadedListConfig;
VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
BaseBranch = cfg?.BaseBranch ?? "";
}
catch (Exception ex)
{
// BeginLoad fires this without awaiting it — catching here (instead of letting it
// propagate) turns what would be an unobserved task exception, with the modal stuck
// open and every field blank, into a reported error and a locked Save button.
_loadFailed = true;
ErrorReported?.Invoke(Loc.T("vm.listSettings.loadFailed", ex.Message));
}
finally { IsLoading = false; }
if (_loadFailed) return;
// Deliberately NOT awaited: an unreachable ticket system (10s HTTP timeout) must not hold
// up the rest of the modal. The dropdown just stays empty until this finishes.
_ = LoadTicketProjectsAsync(cfg?.TicketProjectId);
}
private async Task LoadTicketProjectsAsync(int? existingTicketProjectId)
{
_ticketProjectsLoaded = false;
TicketProjects.Clear();
try
{
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
? await _worker.GetTicketSettingsAsync()
: null;
TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl);
if (!TicketsAvailable)
{
SelectedTicketProject = null;
return;
}
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 = existingTicketProjectId is int id
? TicketProjects.FirstOrDefault(t => t.Id == id) ?? TicketProjects[0]
: TicketProjects[0];
_ticketProjectsLoaded = true;
}
catch (Exception ex)
{
// Ticket system unreachable/slow — leave the dropdown as-is. Save's tri-state (below)
// skips writing TicketProjectId when _ticketProjectsLoaded is false, so the stored
// link survives instead of being wiped to "none".
ErrorReported?.Invoke(Loc.T("vm.listSettings.ticketProjectsLoadFailed", ex.Message));
}
}
private bool CanSave() => !IsLoading && !_loadFailed && (IsFolder || string.IsNullOrEmpty(WorkingDirWarning));
private bool CanDelete() => !IsLoading;
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
// otherwise only surface as a Failed task the first time the worker tries to use it.
// The create-flow toggle swaps the untouched default so a new folder doesn't get the list icon.
partial void OnIsFolderChanged(bool value)
{
if (Icon == (value ? DefaultIcon : DefaultFolderIcon))
Icon = value ? DefaultFolderIcon : DefaultIcon;
}
partial void OnWorkingDirChanged(string value)
{
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
WorkingDirWarning = !Directory.Exists(value)
? Loc.T("vm.listSettings.workingDirMissing")
: RepoScanner.IsRepo(value) ? "" : Loc.T("vm.listSettings.workingDirNotRepo");
}
[RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync()
{
if (IsFolder)
{
await SaveFolderAsync();
return;
}
await _worker.UpdateListAsync(new UpdateListDto(
ListId,
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType,
IsManual,
FindingsTracked,
Icon,
NullIfEmpty(SelectedColor)));
// Tri-state: dropdown never shown (no base URL), or ticket projects failed/never loaded
// (e.g. an unreachable ticket system) -> null, leave the stored link alone. Dropdown shown
// and loaded -> always an explicit value, 0 for "— none —" to actively clear it. Never
// null in that case, or an explicit unlink would silently fail to reach the worker.
var ticketProjectId = TicketsAvailable && _ticketProjectsLoaded ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
// the worker as a real false rather than a "leave it alone".
await Agent.SaveAsync(
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
ticketProjectId,
PowerUseAllowed,
// Always explicit too: "" clears the stored base branch.
BaseBranch.Trim());
Saved = true;
CloseAction?.Invoke();
}
private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s;
private async Task SaveFolderAsync()
{
var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
if (_folderId is null)
{
await lists.AddFolderAsync(new ListFolderEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = name,
Icon = Icon,
Color = NullIfEmpty(SelectedColor),
CreatedAt = DateTime.UtcNow,
});
// The create flow pre-creates a list row for this modal to save against; a folder
// doesn't need it. Deleted=true (it genuinely is) makes the caller do a full reload,
// which is what picks the new folder up.
await lists.DeleteAsync(ListId);
Deleted = true;
}
else
{
await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(SelectedColor));
}
Saved = true;
CloseAction?.Invoke();
}
[RelayCommand(CanExecute = nameof(CanDelete))]
private async Task DeleteAsync()
{
if (IsFolder)
{
await DeleteFolderAsync();
return;
}
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteList", displayName));
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(Loc.T("vm.listSettings.deleteBlockedPlanning"));
return;
}
Deleted = true;
CloseAction?.Invoke();
}
private async Task DeleteFolderAsync()
{
// Create mode with the folder toggle on: nothing has been written yet, so this is a cancel
// — and the caller's rollback drops the pre-created list row.
if (_folderId is null) { CloseAction?.Invoke(); return; }
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteFolder", displayName));
if (!ok) return;
}
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new ListRepository(ctx).DeleteFolderAsync(_folderId);
}
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
return;
}
Deleted = true;
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}