931 lines
39 KiB
C#
931 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";
|
|
|
|
/// <summary>Icon key a list falls back to — deliberately not the folder icon, so the two stay distinguishable.</summary>
|
|
public const string ListIcon = ListSettingsModalViewModel.DefaultIcon;
|
|
|
|
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, color: row.Color);
|
|
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 = "mdi:WeatherSunny" },
|
|
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "mdi:NoteTextOutline" },
|
|
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "mdi:StarOutline" },
|
|
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "mdi:CalendarOutline" },
|
|
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "mdi:InboxOutline" },
|
|
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "mdi:Pulse" },
|
|
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "mdi:EyeOutline" },
|
|
};
|
|
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,
|
|
Color = f.Color,
|
|
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 ?? ListIcon,
|
|
Color = l.Color,
|
|
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 = ListIcon,
|
|
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, color: entity.Color);
|
|
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, folder.Color);
|
|
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 ?? ListIcon;
|
|
row.Color = entity.Color;
|
|
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 ?? ListIcon,
|
|
Color = entity.Color,
|
|
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();
|
|
}
|
|
}
|