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 } /// 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). public sealed record MergeHelperRequest(string ListId, IReadOnlyList TaskIds, string? TriageModel = null); public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable { /// 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. public const string NotesListId = "smart:notes"; /// Icon key a folder falls back to when it has none of its own. public const string ListFolderIcon = "Folder"; private readonly IDbContextFactory _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? ErrorReported; // Same strip, but for an action that succeeded and has a result worth showing. public event Action? SuccessReported; public IDialogService? Dialogs { get; set; } [RelayCommand] private async Task OpenSettings() { if (Dialogs is null || _services is null) return; var settingsVm = _services.GetRequiredService(); 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(); 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(); 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(); vm.ErrorReported += msg => ErrorReported?.Invoke(msg); vm.Configure(rawId, row.Name); await vm.LoadAsync(); await Dialogs.ShowWorktreesOverviewAsync(vm); } finally { _worktreesOverviewOpen = false; } } /// Raised after the merge-helper selection dialog is confirmed; the shell opens the ConPTY tile. public event Action? 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(); 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)); } /// Raised once per list after the review-scoped selection dialog is confirmed; /// the shell opens one merge-phase ConPTY tile per request. public event Action? 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(); 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 Items { get; } = new(); public ObservableCollection SmartLists { get; } = new(); /// Every user list, flat, regardless of folder — the set the count refresh, the /// row refresh and the no-repo banner work over. public ObservableCollection UserLists { get; } = new(); /// The sidebar's top level, in render order: folder rows /// () and folder-less lists /// () interleaved, so a list can sit above a folder. /// This is what the view binds to; stays the flat superset. public ObservableCollection TopLevel { get; } = new(); /// Every sidebar folder, at any depth — a recursive projection of /// , not a second collection to keep in sync. public IReadOnlyList Folders => AllFolders(TopLevel).ToList(); private static IEnumerable AllFolders(IEnumerable 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; } } /// The user lists that sit outside any folder — likewise a projection. public IReadOnlyList RootLists => TopLevel.OfType().ToList(); [ObservableProperty] private string _searchText = ""; [ObservableProperty] private ListNavItemViewModel? _selectedList; /// True whenever no User list has a linked WorkingDir — 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. [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 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>(); var foldersById = new Dictionary(); 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(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(); } /// /// 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. /// 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(); 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; } } /// Marks the insert position for a drop next to — either a /// list row or a folder header (folders take the same two hint lines, drawn around the whole /// folder block). 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; } /// The collection a row is rendered in: its folder's Children, or /// when it has no folder. Non-generic because the top level is mixed. public System.Collections.IList ContainerOf(ListNavItemViewModel row) => Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Children ?? (System.Collections.IList)TopLevel; /// The collection a folder is rendered in: its parent's Children, or /// when it has none. public System.Collections.IList ContainerOf(ListFolderNavItemViewModel folder) => Folders.FirstOrDefault(f => f.Id == folder.ParentFolderId)?.Children ?? (System.Collections.IList)TopLevel; /// True when is itself or /// nested anywhere under it — the guard against dropping a folder into its own subtree. 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()) if (IsSelfOrDescendant(child, candidateId)) return true; return false; } /// Kept for the list→list case; covers all four combinations. public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) => DropAsync(source, target, placeBelow); /// /// Drops (a list row or a folder) at '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. /// 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); } /// Appends a row (list or folder) to (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. 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); } /// /// Writes the sidebar's visual order back as a single global sort_order sequence, /// walked depth-first (a folder, then its children, at any depth) and, when a row changed /// container, its new folder_id/parent_folder_id. /// private async Task PersistOrderAsync(object? movedItem) { var ordered = new List<(string Id, bool IsFolder)>(); void Walk(IEnumerable 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(); } /// Context-menu counterpart to dragging a list out of a folder. [RelayCommand] private async Task RemoveFromFolderAsync(ListNavItemViewModel? row) { if (row is null) return; await MoveToFolderAsync(row, null); } private readonly Dictionary _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(); 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 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); } } /// /// 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. /// 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(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(); } }