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 _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>? ConfirmAsync { get; set; } public Func? ShowErrorAsync { get; set; } // Mirrors UsageMonitorModalViewModel.ErrorReported — the caller wires this into FlashFooterError. public event Action? 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." StreamGeometry). [ObservableProperty] private string _icon = DefaultIcon; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowTicketProject))] private bool _ticketsAvailable; /// The dropdown needs a configured ticket system *and* a list — folders link to nothing. 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; /// True only while creating — an existing list can't become a folder or back. [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 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 CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); public const string DefaultIcon = "Dot"; /// Picker swatches: "" (default) followed by the accent-aware palette keys. public static readonly string[] SwatchOptions = ["", .. AccentPresets.SwatchKeys]; /// Stored colour: "" (default), a swatch key, or a literal "#RRGGBB". Shared by lists and folders. [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCustomColor))] private string _selectedColor = ""; /// True when is a custom hex rather than a swatch. 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 = ""; /// Bound to the custom ColorPicker; writes back as "#RRGGBB". [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}"; } /// Default icon for folders, kept apart from so lists never look like folders. 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(); /// Cap on search hits so the picker's WrapPanel stays responsive. 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() .GroupBy(Enum.Parse) .Select(g => (IconKeyConverter.MdiPrefix + g.First(), string.Join(' ', g))) .ToArray()); /// Picker items: the curated set, or MDI search hits once the search has 2+ characters. [ObservableProperty] private IReadOnlyList _icons = IconOptions; /// Free-text filter over the MDI icon names (English). [ObservableProperty] private string _iconSearch = ""; /// True when the search hit more than icons. [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; /// True for a curated key, a legacy bare key still stored in the DB, or an existing "mdi:" kind. 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(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 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); } /// /// 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. /// 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(); }