Folders can now contain other folders, to arbitrary depth, via a self-referencing parent_folder_id (ON DELETE SET NULL, same pattern as lists.folder_id). Nesting/un-nesting is drag-and-drop only: dropping a folder onto another folder's header middle moves it in, the outer quarters or any top-level/in-folder drop insert it beside a target. Dropping a folder into itself or one of its own subfolders is silently ignored. Deleting a folder releases its lists and subfolders back to the top level, unchanged from the existing list behavior. ListFolderNavItemViewModel.Lists became Children (object, mixing lists and subfolders, mirroring TopLevel) so the existing implicit, type-matched DataTemplates render nested folders recursively without new XAML, and each nesting level gets its indent for free from the folder template's own margin. Folders/RootLists/ContainerOf/PersistOrderAsync/RecomputeFolderCounts now walk the tree instead of assuming one level. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
924 lines
39 KiB
C#
924 lines
39 KiB
C#
using System.Collections.ObjectModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Filtering;
|
|
using ClaudeDo.Data.Models;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
|
|
|
public enum ListKind { Smart, Virtual, User }
|
|
|
|
/// <summary>Confirmed handler run: the scope list, the ordered selected task ids, and the model
|
|
/// picked for the triage session (null = default; review-mode runs skip triage entirely).</summary>
|
|
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds, string? TriageModel = null);
|
|
|
|
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|
{
|
|
/// <summary>The day-log list. A smart list with no tasks in it: selecting it makes the tasks
|
|
/// island show the notes editor instead of rows.</summary>
|
|
public const string NotesListId = "smart:notes";
|
|
|
|
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
|
|
public const string ListFolderIcon = "Folder";
|
|
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly IServiceProvider? _services;
|
|
private readonly IWorkerClient? _worker;
|
|
private static readonly TaskListFilterRegistry _filters = new();
|
|
|
|
public event EventHandler? SelectionChanged;
|
|
public event EventHandler? FocusSearchRequested;
|
|
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
|
|
|
|
// mirrors TasksIslandViewModel.ErrorReported — surfaces modal-owned failures in the footer strip.
|
|
public event Action<string>? ErrorReported;
|
|
|
|
// Same strip, but for an action that succeeded and has a result worth showing.
|
|
public event Action<string>? SuccessReported;
|
|
|
|
public IDialogService? Dialogs { get; set; }
|
|
|
|
[RelayCommand]
|
|
private async Task OpenSettings()
|
|
{
|
|
if (Dialogs is null || _services is null) return;
|
|
var settingsVm = _services.GetRequiredService<SettingsModalViewModel>();
|
|
await settingsVm.LoadAsync();
|
|
await Dialogs.ShowSettingsAsync(settingsVm);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
|
|
{
|
|
if (row is null || Dialogs is null || _services is null) return;
|
|
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
|
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
|
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
|
await Dialogs.ShowListSettingsAsync(vm);
|
|
if (vm.Deleted) await LoadAsync();
|
|
else await RefreshRowAsync(row.Id);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async System.Threading.Tasks.Task OpenRepoImportAsync()
|
|
{
|
|
if (Dialogs is null || _services is null) return;
|
|
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
|
|
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
|
await vm.LoadAsync();
|
|
await Dialogs.ShowRepoImportAsync(vm);
|
|
await LoadAsync();
|
|
}
|
|
|
|
private bool _worktreesOverviewOpen;
|
|
|
|
[RelayCommand]
|
|
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
|
|
{
|
|
if (row is null || Dialogs is null || _services is null) return;
|
|
if (row.Kind != ListKind.User) return;
|
|
if (_worktreesOverviewOpen) return;
|
|
_worktreesOverviewOpen = true;
|
|
try
|
|
{
|
|
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
|
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
|
|
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
|
vm.Configure(rawId, row.Name);
|
|
await vm.LoadAsync();
|
|
await Dialogs.ShowWorktreesOverviewAsync(vm);
|
|
}
|
|
finally { _worktreesOverviewOpen = false; }
|
|
}
|
|
|
|
/// <summary>Raised after the merge-helper selection dialog is confirmed; the shell opens the ConPTY tile.</summary>
|
|
public event Action<MergeHelperRequest>? LetClaudeHandleRequested;
|
|
|
|
[RelayCommand]
|
|
private async Task LetClaudeHandleListAsync(ListNavItemViewModel? row)
|
|
{
|
|
if (row is null || Dialogs is null || _services is null) return;
|
|
if (row.Kind != ListKind.User || string.IsNullOrWhiteSpace(row.WorkingDir)) return;
|
|
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
|
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
|
|
vm.Configure(rawId, row.Name);
|
|
await vm.LoadAsync();
|
|
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
|
|
if (ids is { Count: > 0 })
|
|
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids, vm.SelectedModel));
|
|
}
|
|
|
|
/// <summary>Raised once per list after the review-scoped selection dialog is confirmed;
|
|
/// the shell opens one merge-phase ConPTY tile per request.</summary>
|
|
public event Action<MergeHelperRequest>? MergeReviewsRequested;
|
|
|
|
// Review-list variant of LetClaudeHandleListAsync: offers every WaitingForReview task across
|
|
// all repo-linked lists and starts one merge-phase handler session per list picked.
|
|
[RelayCommand]
|
|
private async Task MergeReviewsAsync()
|
|
{
|
|
if (Dialogs is null || _services is null) return;
|
|
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
|
|
vm.ConfigureForReview();
|
|
await vm.LoadAsync();
|
|
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
|
|
if (ids is not { Count: > 0 }) return;
|
|
foreach (var (listId, taskIds) in vm.GroupSelectionByList(ids))
|
|
MergeReviewsRequested?.Invoke(new MergeHelperRequest(listId, taskIds));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenInExplorer(ListNavItemViewModel? row)
|
|
{
|
|
var (ok, error) = ShellOpen.Path(row?.WorkingDir);
|
|
if (!ok && error is not null)
|
|
ErrorReported?.Invoke(Loc.T("vm.lists.openInExplorerFailed", error));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenFindings(ListNavItemViewModel? row)
|
|
{
|
|
var dir = row?.WorkingDir;
|
|
if (string.IsNullOrWhiteSpace(dir)) return;
|
|
var findings = System.IO.Path.Combine(dir, ".claudedo");
|
|
if (!System.IO.Directory.Exists(findings))
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("vm.lists.findingsNotFound"));
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = findings,
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.lists.findingsOpenFailed", ex.Message)); }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenInTerminal(ListNavItemViewModel? row)
|
|
{
|
|
var dir = row?.WorkingDir;
|
|
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
|
|
// Trailing separator + ArgumentList, not string interpolation: "C:\repo\" would parse as
|
|
// C:\repo" and wt would refuse it as a starting directory (Paths.TrimTrailingSeparator).
|
|
// Rows loaded before ListRepository normalized on write can still carry one.
|
|
dir = Paths.TrimTrailingSeparator(dir)!;
|
|
ForegroundHelper.AllowAny();
|
|
try
|
|
{
|
|
var psi = new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "wt.exe",
|
|
UseShellExecute = true,
|
|
};
|
|
psi.ArgumentList.Add("-d");
|
|
psi.ArgumentList.Add(dir);
|
|
System.Diagnostics.Process.Start(psi);
|
|
}
|
|
catch
|
|
{
|
|
// Windows Terminal not installed — fall back to a plain console at the directory.
|
|
try
|
|
{
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "cmd.exe",
|
|
WorkingDirectory = dir,
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
|
|
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
|
|
/// <summary>Every user list, flat, regardless of folder — the set the count refresh, the
|
|
/// row refresh and the no-repo banner work over.</summary>
|
|
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
|
|
/// <summary>The sidebar's top level, in render order: folder rows
|
|
/// (<see cref="ListFolderNavItemViewModel"/>) and folder-less lists
|
|
/// (<see cref="ListNavItemViewModel"/>) interleaved, so a list can sit above a folder.
|
|
/// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary>
|
|
public ObservableCollection<object> TopLevel { get; } = new();
|
|
|
|
/// <summary>Every sidebar folder, at any depth — a recursive projection of
|
|
/// <see cref="TopLevel"/>, not a second collection to keep in sync.</summary>
|
|
public IReadOnlyList<ListFolderNavItemViewModel> Folders => AllFolders(TopLevel).ToList();
|
|
|
|
private static IEnumerable<ListFolderNavItemViewModel> AllFolders(IEnumerable<object> items)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
if (item is not ListFolderNavItemViewModel folder) continue;
|
|
yield return folder;
|
|
foreach (var descendant in AllFolders(folder.Children))
|
|
yield return descendant;
|
|
}
|
|
}
|
|
/// <summary>The user lists that sit outside any folder — likewise a projection.</summary>
|
|
public IReadOnlyList<ListNavItemViewModel> RootLists =>
|
|
TopLevel.OfType<ListNavItemViewModel>().ToList();
|
|
|
|
[ObservableProperty] private string _searchText = "";
|
|
[ObservableProperty] private ListNavItemViewModel? _selectedList;
|
|
|
|
/// <summary>True whenever no User list has a linked <c>WorkingDir</c> — drives the no-repo
|
|
/// banner. Smart/Virtual lists never count (scope intentionally narrower than
|
|
/// SettingsModalViewModel.HasLinkedRepo, which checks all Lists rows in the DB). Recomputed
|
|
/// after every load and every list CRUD / list-settings save; never persisted, no dismiss
|
|
/// state.</summary>
|
|
[ObservableProperty] private bool _hasNoLinkedRepo = true;
|
|
|
|
private void RecomputeHasNoLinkedRepo() =>
|
|
HasNoLinkedRepo = UserLists.All(r => !RepoLinkage.IsLinked(r.WorkingDir));
|
|
|
|
public string UserName { get; } = Environment.UserName;
|
|
public string MachineName { get; } = Environment.MachineName;
|
|
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
|
|
public string UserInitials { get; }
|
|
|
|
private readonly EventHandler _langChangedHandler;
|
|
|
|
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, IWorkerClient? worker = null)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_services = services;
|
|
_worker = worker;
|
|
var parts = Environment.UserName.Split('.', '_', '-', ' ');
|
|
UserInitials = parts.Length >= 2
|
|
? $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant()
|
|
: Environment.UserName.Length >= 2
|
|
? Environment.UserName[..2].ToUpperInvariant()
|
|
: Environment.UserName.ToUpperInvariant();
|
|
|
|
if (_worker is not null)
|
|
{
|
|
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
|
|
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
|
|
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
|
|
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
|
|
_worker.WorktreeUpdatedEvent += _id => _ = RefreshCountsAsync();
|
|
_worker.ConnectionRestoredEvent += () => _ = ReloadAfterReconnectAsync();
|
|
}
|
|
|
|
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
|
|
Loc.LanguageChanged += _langChangedHandler;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Loc.LanguageChanged -= _langChangedHandler;
|
|
}
|
|
|
|
private static string? SmartListNameKey(string id) => id switch
|
|
{
|
|
"smart:my-day" => "vm.lists.smartMyDay",
|
|
"smart:important" => "vm.lists.smartImportant",
|
|
"smart:planned" => "vm.lists.smartPlanned",
|
|
NotesListId => "vm.lists.smartNotes",
|
|
"virtual:queued" => "vm.lists.virtualQueue",
|
|
"virtual:running" => "vm.lists.virtualRunning",
|
|
"virtual:review" => "vm.lists.virtualReview",
|
|
_ => null,
|
|
};
|
|
|
|
private void RefreshLocalizedLabels()
|
|
{
|
|
foreach (var item in SmartLists)
|
|
if (SmartListNameKey(item.Id) is { } key) item.Name = Loc.T(key);
|
|
OnPropertyChanged(nameof(MachineNameLocal));
|
|
}
|
|
|
|
public async Task LoadAsync(CancellationToken ct = default)
|
|
{
|
|
Items.Clear();
|
|
SmartLists.Clear();
|
|
UserLists.Clear();
|
|
TopLevel.Clear();
|
|
|
|
var smart = new[]
|
|
{
|
|
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
|
|
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" },
|
|
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" },
|
|
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" },
|
|
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" },
|
|
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" },
|
|
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" },
|
|
};
|
|
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var ok = false;
|
|
try
|
|
{
|
|
var ticketsConfigured = await IsTicketsConfiguredAsync();
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var lists = new ListRepository(ctx);
|
|
|
|
// Every container (the top level, and each folder's children) gets its own ordered
|
|
// bucket keyed by sort_order, since folders and lists at any depth share one sort_order
|
|
// space. Buckets are keyed by parent folder id; null (via topLevelBucket) is the top level.
|
|
var topLevelBucket = new List<(int Sort, object Item)>();
|
|
var childBuckets = new Dictionary<string, List<(int Sort, object Item)>>();
|
|
var foldersById = new Dictionary<string, ListFolderNavItemViewModel>();
|
|
|
|
List<(int Sort, object Item)> BucketFor(string? parentFolderId) =>
|
|
parentFolderId is { } pid && foldersById.ContainsKey(pid)
|
|
? childBuckets.TryGetValue(pid, out var b) ? b : childBuckets[pid] = new()
|
|
: topLevelBucket;
|
|
|
|
foreach (var f in await lists.GetFoldersAsync(ct))
|
|
{
|
|
var folder = new ListFolderNavItemViewModel
|
|
{
|
|
Id = f.Id,
|
|
Name = f.Name,
|
|
IconKey = f.Icon ?? ListFolderIcon,
|
|
IsCollapsed = f.IsCollapsed,
|
|
ParentFolderId = f.ParentFolderId,
|
|
};
|
|
foldersById[f.Id] = folder;
|
|
// Parent always precedes its children in sort_order (PersistOrderAsync walks
|
|
// depth-first), so the parent bucket already exists by the time a child is seen.
|
|
BucketFor(f.ParentFolderId).Add((f.SortOrder, folder));
|
|
}
|
|
|
|
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
|
foreach (var l in await lists.GetAllAsync(ct))
|
|
{
|
|
if (seedNames.Contains(l.Name)) continue;
|
|
var cfg = await lists.GetConfigAsync(l.Id, ct);
|
|
var item = new ListNavItemViewModel
|
|
{
|
|
Id = $"user:{l.Id}",
|
|
Name = l.Name,
|
|
Kind = ListKind.User,
|
|
IconKey = l.Icon ?? "Folder",
|
|
WorkingDir = l.WorkingDir,
|
|
DefaultCommitType = l.DefaultCommitType,
|
|
IsManual = l.IsManual,
|
|
FindingsTracked = l.FindingsTracked,
|
|
HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0,
|
|
FolderId = l.FolderId,
|
|
};
|
|
Items.Add(item);
|
|
UserLists.Add(item);
|
|
BucketFor(item.FolderId).Add((l.SortOrder, item));
|
|
}
|
|
|
|
// OrderBy is stable, so legacy rows (folders and lists each numbered from 0
|
|
// independently) keep the old look — folders first — until the first drag.
|
|
void Materialize(List<(int Sort, object Item)> bucket, System.Collections.IList target)
|
|
{
|
|
foreach (var (_, item) in bucket.OrderBy(t => t.Sort))
|
|
{
|
|
target.Add(item);
|
|
if (item is ListFolderNavItemViewModel folder && childBuckets.TryGetValue(folder.Id, out var childBucket))
|
|
Materialize(childBucket, folder.Children);
|
|
}
|
|
}
|
|
Materialize(topLevelBucket, TopLevel);
|
|
ok = true;
|
|
}
|
|
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
|
|
|
|
RecomputeHasNoLinkedRepo();
|
|
await RefreshCountsAsync(ct);
|
|
SelectedList = Items.FirstOrDefault();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A reconnect can have missed offline list creates/renames/deletes (SignalR was down) — unlike
|
|
/// the task/worktree events, which only need a count refresh — so this re-reads the full list
|
|
/// set instead, restoring the prior selection if it still exists.
|
|
/// </summary>
|
|
private async Task ReloadAfterReconnectAsync()
|
|
{
|
|
var selectedId = SelectedList?.Id;
|
|
await LoadAsync();
|
|
if (selectedId is not null && Items.FirstOrDefault(i => i.Id == selectedId) is { } restored)
|
|
SelectedList = restored;
|
|
}
|
|
|
|
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
|
{
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var ok = false;
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
|
|
// Single snapshot; counters and the list loader share the same filter strategies.
|
|
var all = await ctx.Tasks.AsNoTracking()
|
|
.Include(t => t.Worktree)
|
|
.ToListAsync(ct);
|
|
|
|
foreach (var item in SmartLists)
|
|
{
|
|
var filter = _filters.Resolve(item.Id);
|
|
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
|
}
|
|
|
|
foreach (var item in UserLists)
|
|
{
|
|
var filter = _filters.Resolve(item.Id);
|
|
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
|
}
|
|
|
|
RecomputeFolderCounts();
|
|
ok = true;
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch { /* best-effort refresh */ }
|
|
finally { OperationTiming.Shared.Record("db", "ListsIsland.RefreshCountsAsync", sw.Elapsed, ok); }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Select(ListNavItemViewModel item) => SelectedList = item;
|
|
|
|
[RelayCommand]
|
|
private async Task CreateListAsync()
|
|
{
|
|
var entity = new ListEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Name = Loc.T("vm.lists.newList"),
|
|
DefaultCommitType = CommitTypeRegistry.DefaultType,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
await using (var ctx = await _dbFactory.CreateDbContextAsync())
|
|
{
|
|
var lists = new ListRepository(ctx);
|
|
await lists.AddAsync(entity);
|
|
}
|
|
|
|
var item = new ListNavItemViewModel
|
|
{
|
|
Id = $"user:{entity.Id}",
|
|
Name = entity.Name,
|
|
Kind = ListKind.User,
|
|
IconKey = "Folder",
|
|
WorkingDir = entity.WorkingDir,
|
|
DefaultCommitType = entity.DefaultCommitType,
|
|
};
|
|
Items.Add(item);
|
|
UserLists.Add(item);
|
|
TopLevel.Add(item);
|
|
SelectedList = item;
|
|
RecomputeHasNoLinkedRepo();
|
|
|
|
if (Dialogs is not null && _services is not null)
|
|
{
|
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
|
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
|
// canToggleFolder: only the create flow offers "make this a folder instead".
|
|
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true);
|
|
await Dialogs.ShowListSettingsAsync(vm);
|
|
if (!vm.Saved && !vm.Deleted)
|
|
{
|
|
// Cancel/close right after "New list" means "never mind" — drop the row we
|
|
// pre-created, otherwise an untitled empty list is left behind.
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
await new ListRepository(ctx).DeleteAsync(entity.Id);
|
|
await LoadAsync();
|
|
}
|
|
else if (vm.Deleted) await LoadAsync();
|
|
else await RefreshRowAsync(item.Id);
|
|
}
|
|
}
|
|
|
|
public void ClearDropHints()
|
|
{
|
|
foreach (var r in UserLists)
|
|
{
|
|
r.DropHintAbove = false;
|
|
r.DropHintBelow = false;
|
|
}
|
|
foreach (var f in Folders)
|
|
{
|
|
f.IsDropTarget = false;
|
|
f.DropHintAbove = false;
|
|
f.DropHintBelow = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Marks the insert position for a drop next to <paramref name="target"/> — either a
|
|
/// list row or a folder header (folders take the same two hint lines, drawn around the whole
|
|
/// folder block).</summary>
|
|
public void SetDropHint(object target, bool placeBelow)
|
|
{
|
|
ClearDropHints();
|
|
switch (target)
|
|
{
|
|
case ListNavItemViewModel row:
|
|
row.DropHintAbove = !placeBelow;
|
|
row.DropHintBelow = placeBelow;
|
|
break;
|
|
case ListFolderNavItemViewModel folder:
|
|
folder.DropHintAbove = !placeBelow;
|
|
folder.DropHintBelow = placeBelow;
|
|
break;
|
|
}
|
|
}
|
|
|
|
public void SetFolderDropTarget(ListFolderNavItemViewModel? target)
|
|
{
|
|
ClearDropHints();
|
|
if (target is not null) target.IsDropTarget = true;
|
|
}
|
|
|
|
/// <summary>The collection a row is rendered in: its folder's <c>Children</c>, or
|
|
/// <see cref="TopLevel"/> when it has no folder. Non-generic because the top level is mixed.</summary>
|
|
public System.Collections.IList ContainerOf(ListNavItemViewModel row) =>
|
|
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Children ?? (System.Collections.IList)TopLevel;
|
|
|
|
/// <summary>The collection a folder is rendered in: its parent's <c>Children</c>, or
|
|
/// <see cref="TopLevel"/> when it has none.</summary>
|
|
public System.Collections.IList ContainerOf(ListFolderNavItemViewModel folder) =>
|
|
Folders.FirstOrDefault(f => f.Id == folder.ParentFolderId)?.Children ?? (System.Collections.IList)TopLevel;
|
|
|
|
/// <summary>True when <paramref name="candidateId"/> is <paramref name="folder"/> itself or
|
|
/// nested anywhere under it — the guard against dropping a folder into its own subtree.</summary>
|
|
private static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, string? candidateId)
|
|
{
|
|
if (candidateId is null) return false;
|
|
if (candidateId == folder.Id) return true;
|
|
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
|
|
if (IsSelfOrDescendant(child, candidateId)) return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary>
|
|
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
|
|
DropAsync(source, target, placeBelow);
|
|
|
|
/// <summary>
|
|
/// Drops <paramref name="source"/> (a list row or a folder) at <paramref name="target"/>'s
|
|
/// position. Since a drop position implies a container, dropping a row onto a row in a
|
|
/// different folder (or next to anything at the top level) also *moves* it there — that's the
|
|
/// only way to drag something out of a folder. A folder dropped next to a target inside its own
|
|
/// subtree is ignored rather than nested into itself.
|
|
/// </summary>
|
|
public async Task DropAsync(object source, object target, bool placeBelow)
|
|
{
|
|
if (ReferenceEquals(source, target)) return;
|
|
if (target is ListNavItemViewModel { Kind: not ListKind.User }) return;
|
|
|
|
if (source is ListFolderNavItemViewModel folder)
|
|
{
|
|
var newParentId = target switch
|
|
{
|
|
ListFolderNavItemViewModel tf => tf.ParentFolderId,
|
|
ListNavItemViewModel tl => tl.FolderId,
|
|
_ => null,
|
|
};
|
|
if (IsSelfOrDescendant(folder, newParentId)) return;
|
|
|
|
var targetContainer = target switch
|
|
{
|
|
ListFolderNavItemViewModel tf => ContainerOf(tf),
|
|
ListNavItemViewModel tl => ContainerOf(tl),
|
|
_ => (System.Collections.IList)TopLevel,
|
|
};
|
|
var parentChanged = folder.ParentFolderId != newParentId;
|
|
if (parentChanged)
|
|
{
|
|
ContainerOf(folder).Remove(folder);
|
|
folder.ParentFolderId = newParentId;
|
|
targetContainer.Add(folder);
|
|
}
|
|
|
|
MoveWithinCollection(targetContainer, folder, target, placeBelow);
|
|
await PersistOrderAsync(folder);
|
|
return;
|
|
}
|
|
|
|
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
|
|
|
|
// Next to a folder *header* means that folder's own container, not inside it —
|
|
// MoveToFolderAsync is the drop-into-the-folder path.
|
|
var targetFolderId = target switch
|
|
{
|
|
ListNavItemViewModel t => t.FolderId,
|
|
ListFolderNavItemViewModel tf => tf.ParentFolderId,
|
|
_ => null,
|
|
};
|
|
var folderChanged = row.FolderId != targetFolderId;
|
|
if (folderChanged)
|
|
{
|
|
ContainerOf(row).Remove(row);
|
|
row.FolderId = targetFolderId;
|
|
ContainerOf(row).Add(row);
|
|
}
|
|
|
|
MoveWithinCollection(ContainerOf(row), row, target, placeBelow);
|
|
await PersistOrderAsync(folderChanged ? row : null);
|
|
}
|
|
|
|
/// <summary>Appends a row (list or folder) to <paramref name="folder"/> (or to the top level
|
|
/// when null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty
|
|
/// folders. A folder source dropped into itself or one of its own subfolders is ignored.</summary>
|
|
public async Task MoveToFolderAsync(object source, ListFolderNavItemViewModel? folder)
|
|
{
|
|
if (source is ListFolderNavItemViewModel sourceFolder)
|
|
{
|
|
if (ReferenceEquals(sourceFolder, folder)) return;
|
|
if (IsSelfOrDescendant(sourceFolder, folder?.Id)) return;
|
|
if (sourceFolder.ParentFolderId == folder?.Id) return;
|
|
|
|
ContainerOf(sourceFolder).Remove(sourceFolder);
|
|
sourceFolder.ParentFolderId = folder?.Id;
|
|
ContainerOf(sourceFolder).Add(sourceFolder);
|
|
await PersistOrderAsync(sourceFolder);
|
|
return;
|
|
}
|
|
|
|
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
|
|
if (row.FolderId == folder?.Id) return;
|
|
|
|
ContainerOf(row).Remove(row);
|
|
row.FolderId = folder?.Id;
|
|
ContainerOf(row).Add(row);
|
|
await PersistOrderAsync(row);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence,
|
|
/// walked depth-first (a folder, then its children, at any depth) and, when a row changed
|
|
/// container, its new <c>folder_id</c>/<c>parent_folder_id</c>.
|
|
/// </summary>
|
|
private async Task PersistOrderAsync(object? movedItem)
|
|
{
|
|
var ordered = new List<(string Id, bool IsFolder)>();
|
|
void Walk(IEnumerable<object> items)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
switch (item)
|
|
{
|
|
case ListFolderNavItemViewModel f:
|
|
ordered.Add((f.Id, true));
|
|
Walk(f.Children);
|
|
break;
|
|
case ListNavItemViewModel l:
|
|
ordered.Add((RawId(l.Id), false));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Walk(TopLevel);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var ok = false;
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var lists = new ListRepository(ctx);
|
|
switch (movedItem)
|
|
{
|
|
case ListNavItemViewModel movedRow:
|
|
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
|
|
break;
|
|
case ListFolderNavItemViewModel movedFolder:
|
|
await lists.SetFolderParentAsync(movedFolder.Id, movedFolder.ParentFolderId);
|
|
break;
|
|
}
|
|
await lists.ReorderSidebarAsync(ordered);
|
|
ok = true;
|
|
}
|
|
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
|
|
|
|
RecomputeFolderCounts();
|
|
}
|
|
|
|
/// <summary>Context-menu counterpart to dragging a list out of a folder.</summary>
|
|
[RelayCommand]
|
|
private async Task RemoveFromFolderAsync(ListNavItemViewModel? row)
|
|
{
|
|
if (row is null) return;
|
|
await MoveToFolderAsync(row, null);
|
|
}
|
|
|
|
private readonly Dictionary<string, CancellationTokenSource> _folderCollapseCts = new();
|
|
|
|
// Synchronous on purpose: the fold is pure UI state and must never miss a click waiting on
|
|
// the AsyncRelayCommand's own CanExecute lock while the DB write below is in flight. The
|
|
// write is fire-and-forget with a per-folder cancellation token so a second toggle before the
|
|
// first one lands cancels it — the DB always ends up with the last-clicked state, never a
|
|
// stale one racing ahead of it.
|
|
[RelayCommand]
|
|
private void ToggleFolder(ListFolderNavItemViewModel? folder)
|
|
{
|
|
if (folder is null) return;
|
|
folder.IsCollapsed = !folder.IsCollapsed;
|
|
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
|
|
var cts = new CancellationTokenSource();
|
|
_folderCollapseCts[folder.Id] = cts;
|
|
_ = PersistFolderCollapsedAsync(folder.Id, folder.IsCollapsed, cts.Token);
|
|
}
|
|
|
|
private async Task PersistFolderCollapsedAsync(string folderId, bool isCollapsed, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
ct.ThrowIfCancellationRequested();
|
|
await new ListRepository(ctx).SetFolderCollapsedAsync(folderId, isCollapsed);
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch (Exception ex)
|
|
{
|
|
// The fold itself already happened in the UI; only its persistence failed.
|
|
ErrorReported?.Invoke(ex.Message);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task OpenFolderSettingsAsync(ListFolderNavItemViewModel? folder)
|
|
{
|
|
if (folder is null || Dialogs is null || _services is null) return;
|
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
|
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
|
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey);
|
|
await Dialogs.ShowListSettingsAsync(vm);
|
|
// Rename, re-icon and delete all change the folder header (and delete releases its lists
|
|
// back to the top level) — one reload covers every outcome.
|
|
if (vm.Saved || vm.Deleted) await LoadAsync();
|
|
}
|
|
|
|
private void RecomputeFolderCounts()
|
|
{
|
|
foreach (var item in TopLevel)
|
|
if (item is ListFolderNavItemViewModel f) ComputeFolderCount(f);
|
|
}
|
|
|
|
private static int ComputeFolderCount(ListFolderNavItemViewModel folder)
|
|
{
|
|
var sum = 0;
|
|
foreach (var child in folder.Children)
|
|
sum += child switch
|
|
{
|
|
ListNavItemViewModel l => l.Count,
|
|
ListFolderNavItemViewModel f => ComputeFolderCount(f),
|
|
_ => 0,
|
|
};
|
|
folder.Count = sum;
|
|
return sum;
|
|
}
|
|
|
|
private static string RawId(string rowId) =>
|
|
rowId.StartsWith("user:", StringComparison.Ordinal) ? rowId["user:".Length..] : rowId;
|
|
|
|
private static void MoveWithinCollection(
|
|
System.Collections.IList coll,
|
|
object source,
|
|
object target,
|
|
bool placeBelow)
|
|
{
|
|
var srcIdx = coll.IndexOf(source);
|
|
var tgtIdx = coll.IndexOf(target);
|
|
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
|
|
|
|
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
|
|
if (srcIdx < finalIdx) finalIdx--;
|
|
if (finalIdx < 0) finalIdx = 0;
|
|
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
|
|
if (finalIdx == srcIdx) return;
|
|
|
|
coll.RemoveAt(srcIdx);
|
|
coll.Insert(finalIdx, source);
|
|
}
|
|
|
|
partial void OnSelectedListChanged(ListNavItemViewModel? value)
|
|
{
|
|
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
|
|
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task RefreshRowAsync(string rowId)
|
|
{
|
|
try
|
|
{
|
|
// The worker broadcasts the raw list id (ListMcpTools/ConfigMcpTools), never the
|
|
// "user:" prefixed row id used in this VM's own collections — match on the raw id
|
|
// either way so an MCP-driven create/rename is recognized against the loaded row.
|
|
var rawId = rowId.StartsWith("user:") ? rowId["user:".Length..] : rowId;
|
|
var row = UserLists.FirstOrDefault(r =>
|
|
(r.Id.StartsWith("user:") ? r.Id["user:".Length..] : r.Id) == rawId);
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var lists = new ListRepository(ctx);
|
|
var entity = await lists.GetByIdAsync(rawId);
|
|
if (entity is null) return;
|
|
var cfg = await lists.GetConfigAsync(rawId);
|
|
var hasTicketProject = await IsTicketsConfiguredAsync() && cfg?.TicketProjectId > 0;
|
|
|
|
if (row is null)
|
|
{
|
|
await AddRowAsync(lists, entity, hasTicketProject);
|
|
return;
|
|
}
|
|
|
|
row.Name = entity.Name;
|
|
row.WorkingDir = entity.WorkingDir;
|
|
row.DefaultCommitType = entity.DefaultCommitType;
|
|
row.IsManual = entity.IsManual;
|
|
row.FindingsTracked = entity.FindingsTracked;
|
|
row.IconKey = entity.Icon ?? "Folder";
|
|
row.HasTicketProject = hasTicketProject;
|
|
RecomputeHasNoLinkedRepo();
|
|
}
|
|
catch { /* best-effort refresh */ }
|
|
}
|
|
|
|
private async Task<bool> IsTicketsConfiguredAsync()
|
|
{
|
|
if (!TicketSettingsTabViewModel.FeatureEnabled) return false;
|
|
if (_worker is null) return false;
|
|
try
|
|
{
|
|
var settings = await _worker.GetTicketSettingsAsync();
|
|
return !string.IsNullOrWhiteSpace(settings?.ApiBaseUrl);
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ImportTicketsAsync(ListNavItemViewModel? row)
|
|
{
|
|
if (row is null || _worker is null) return;
|
|
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
|
try
|
|
{
|
|
var result = await _worker.ImportTicketsAsync(rawId);
|
|
SuccessReported?.Invoke(Loc.T("lists.importTicketsResult", result.Examined, result.Created));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorReported?.Invoke(ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A `create_list` from a running MCP session has no local row yet — build one from the DB
|
|
/// entity and insert it at its DB sort position, instead of waiting for the next full reload.
|
|
/// </summary>
|
|
private async Task AddRowAsync(ListRepository lists, ListEntity entity, bool hasTicketProject = false)
|
|
{
|
|
var item = new ListNavItemViewModel
|
|
{
|
|
Id = $"user:{entity.Id}",
|
|
Name = entity.Name,
|
|
Kind = ListKind.User,
|
|
IconKey = entity.Icon ?? "Folder",
|
|
WorkingDir = entity.WorkingDir,
|
|
DefaultCommitType = entity.DefaultCommitType,
|
|
IsManual = entity.IsManual,
|
|
FindingsTracked = entity.FindingsTracked,
|
|
HasTicketProject = hasTicketProject,
|
|
FolderId = entity.FolderId,
|
|
};
|
|
|
|
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
|
var ordered = (await lists.GetAllAsync()).Where(l => !seedNames.Contains(l.Name)).ToList();
|
|
var dbIndex = ordered.FindIndex(l => l.Id == entity.Id);
|
|
var insertAt = dbIndex < 0 ? UserLists.Count : Math.Min(dbIndex, UserLists.Count);
|
|
|
|
UserLists.Insert(insertAt, item);
|
|
Items.Insert(SmartLists.Count + insertAt, item);
|
|
|
|
// Inside a folder, the same DB-order position; at the top level just appended — the mixed
|
|
// top level has no index that a per-folder DB order maps onto, and AddAsync numbers a new
|
|
// list past everything anyway.
|
|
if (Folders.FirstOrDefault(f => f.Id == item.FolderId) is { } folder)
|
|
{
|
|
var containerIdx = ordered
|
|
.Where(l => l.FolderId == item.FolderId)
|
|
.Select(l => l.Id)
|
|
.ToList()
|
|
.IndexOf(entity.Id);
|
|
folder.Children.Insert(
|
|
containerIdx < 0 || containerIdx > folder.Children.Count ? folder.Children.Count : containerIdx,
|
|
item);
|
|
}
|
|
else TopLevel.Add(item);
|
|
RecomputeHasNoLinkedRepo();
|
|
}
|
|
}
|