Reuses RepoScanner.IsRepo (now public) to validate WorkingDir whenever it changes in ListSettingsModalViewModel — Browse or typing. A missing folder or a non-repo folder shows an inline warning and gates Save via the same CanExecute pattern the load-failure gate already uses; an empty WorkingDir is untouched. Previously this only surfaced as a Failed task deep in WorktreeManager on first run. Also localizes the two hardcoded EN strings in the same file (delete confirm, planning-sessions block) into vm.listSettings.* keys.
405 lines
18 KiB
C#
405 lines
18 KiB
C#
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<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;
|
|
// 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 = "Folder";
|
|
|
|
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
|
|
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
|
|
// Grouped by theme so the picker grid reads in rows; every entry needs a matching
|
|
// "Icon.<key>" StreamGeometry or it renders as an empty slot.
|
|
public static readonly string[] IconOptions =
|
|
[
|
|
// general
|
|
"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",
|
|
];
|
|
|
|
public ObservableCollection<string> Icons { get; } = new(IconOptions);
|
|
|
|
// 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)
|
|
{
|
|
CanToggleFolder = canToggleFolder;
|
|
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
_folderId = folderId;
|
|
IsFolder = true;
|
|
CanToggleFolder = false;
|
|
Name = name;
|
|
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
|
}
|
|
|
|
public async Task LoadAsync(
|
|
string listId,
|
|
string name,
|
|
string? workingDir,
|
|
string defaultCommitType,
|
|
bool isManual = false,
|
|
bool findingsTracked = false,
|
|
string? icon = 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 = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
|
WorkingDir = workingDir ?? "";
|
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
|
|
|
await Agent.LoadForListAsync(listId, ct);
|
|
cfg = Agent.LastLoadedListConfig;
|
|
VerifyCommand = cfg?.VerifyCommand ?? "";
|
|
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
|
|
}
|
|
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.
|
|
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));
|
|
|
|
// 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);
|
|
|
|
Saved = true;
|
|
CloseAction?.Invoke();
|
|
}
|
|
|
|
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,
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|