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>
443 lines
17 KiB
C#
443 lines
17 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Localization;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
|
|
/// <summary>
|
|
/// Sidebar folders: grouping on load, the folder badge, and the two ways a list changes folder
|
|
/// (dropped onto a row in another container, dropped onto a folder header).
|
|
/// </summary>
|
|
public class ListsIslandFolderTests : IDisposable
|
|
{
|
|
private readonly string _dbPath;
|
|
|
|
public ListsIslandFolderTests()
|
|
{
|
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_lists_folders_test_{Guid.NewGuid():N}.db");
|
|
using var ctx = NewContext();
|
|
ctx.Database.EnsureCreated();
|
|
|
|
var dir = AppContext.BaseDirectory;
|
|
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
|
dir = Path.GetDirectoryName(dir);
|
|
Loc.Current = new Localizer(
|
|
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
|
try { File.Delete(_dbPath + suffix); } catch { }
|
|
}
|
|
|
|
private ClaudeDoDbContext NewContext()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
return new ClaudeDoDbContext(opts);
|
|
}
|
|
|
|
private sealed class FakeWorker : StubWorkerClient
|
|
{
|
|
public override bool IsConnected => true;
|
|
}
|
|
|
|
private async Task SeedAsync(string? folderId = null, params (string Id, string Name, int Sort)[] lists) =>
|
|
await SeedAsync(folderId, 0, lists);
|
|
|
|
private async Task SeedAsync(string? folderId, int folderSort, params (string Id, string Name, int Sort)[] lists)
|
|
{
|
|
await using var db = NewContext();
|
|
if (folderId is not null)
|
|
db.ListFolders.Add(new ListFolderEntity { Id = folderId, Name = $"Folder {folderId}", SortOrder = folderSort, CreatedAt = DateTime.UtcNow });
|
|
foreach (var (id, name, sort) in lists)
|
|
db.Lists.Add(new ListEntity { Id = id, Name = name, SortOrder = sort, CreatedAt = DateTime.UtcNow, FolderId = folderId });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task SeedTaskAsync(string listId, int number)
|
|
{
|
|
await using var db = NewContext();
|
|
db.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Number = number,
|
|
ListId = listId,
|
|
Title = "T",
|
|
Status = Data.Models.TaskStatus.Idle,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private ListsIslandViewModel NewVm() => new(new TestDbFactory(NewContext), worker: new FakeWorker());
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_puts_lists_into_their_folder_and_leaves_the_rest_at_the_top_level()
|
|
{
|
|
await SeedAsync("f1", ("inside", "Inside", 0));
|
|
await SeedAsync(null, ("outside", "Outside", 1));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
|
|
var folder = Assert.Single(vm.Folders);
|
|
Assert.Equal("Inside", Assert.Single(folder.Children.OfType<ListNavItemViewModel>()).Name);
|
|
Assert.Equal("Outside", Assert.Single(vm.RootLists).Name);
|
|
// UserLists stays the flat superset — counts and row refreshes work over it.
|
|
Assert.Equal(2, vm.UserLists.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_builds_an_arbitrarily_deep_folder_tree()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "mid", Name = "Mid", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 2, ParentFolderId = "mid", CreatedAt = DateTime.UtcNow });
|
|
db.Lists.Add(new ListEntity { Id = "deep", Name = "Deep", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
|
|
var top = Assert.Single(vm.Folders, f => f.ParentFolderId is null);
|
|
Assert.Equal("top", top.Id);
|
|
var mid = Assert.Single(top.Children.OfType<ListFolderNavItemViewModel>());
|
|
Assert.Equal("mid", mid.Id);
|
|
var leaf = Assert.Single(mid.Children.OfType<ListFolderNavItemViewModel>());
|
|
Assert.Equal("leaf", leaf.Id);
|
|
Assert.Equal("Deep", Assert.Single(leaf.Children.OfType<ListNavItemViewModel>()).Name);
|
|
// The flat recursive projection sees all three levels.
|
|
Assert.Equal(3, vm.Folders.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Folder_count_sums_recursively_through_nested_subfolders()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
|
|
db.Lists.Add(new ListEntity { Id = "a", Name = "A", SortOrder = 2, FolderId = "top", CreatedAt = DateTime.UtcNow });
|
|
db.Lists.Add(new ListEntity { Id = "b", Name = "B", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
await SeedTaskAsync("a", 1);
|
|
await SeedTaskAsync("b", 2);
|
|
await SeedTaskAsync("b", 3);
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
|
|
var top = vm.Folders.Single(f => f.Id == "top");
|
|
// 1 task directly in "a" (under top) + 2 tasks in "b" (under the nested "leaf").
|
|
Assert.Equal(3, top.Count);
|
|
Assert.Equal(2, vm.Folders.Single(f => f.Id == "leaf").Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Folder_count_is_the_sum_of_its_lists()
|
|
{
|
|
await SeedAsync("f1", ("a", "A", 0), ("b", "B", 1));
|
|
await SeedTaskAsync("a", 1);
|
|
await SeedTaskAsync("b", 2);
|
|
await SeedTaskAsync("b", 3);
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
|
|
Assert.Equal(3, Assert.Single(vm.Folders).Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToFolderAsync_moves_a_top_level_list_into_the_folder_and_persists_it()
|
|
{
|
|
await SeedAsync("f1", ("inside", "Inside", 0));
|
|
await SeedAsync(null, ("mover", "Mover", 1));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var mover = vm.RootLists.Single(r => r.Id == "user:mover");
|
|
|
|
await vm.MoveToFolderAsync(mover, vm.Folders[0]);
|
|
|
|
Assert.Empty(vm.RootLists);
|
|
Assert.Equal(2, vm.Folders[0].Children.Count);
|
|
Assert.Equal("f1", mover.FolderId);
|
|
|
|
await using var db = NewContext();
|
|
Assert.Equal("f1", (await db.Lists.AsNoTracking().FirstAsync(l => l.Id == "mover")).FolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToFolderAsync_with_no_folder_takes_a_list_back_out()
|
|
{
|
|
await SeedAsync("f1", ("inside", "Inside", 0));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
|
|
|
|
await vm.MoveToFolderAsync(row, null);
|
|
|
|
Assert.Empty(vm.Folders[0].Children);
|
|
Assert.Same(row, Assert.Single(vm.RootLists));
|
|
Assert.Null(row.FolderId);
|
|
|
|
await using var db = NewContext();
|
|
Assert.Null((await db.Lists.AsNoTracking().FirstAsync(l => l.Id == "inside")).FolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReorderAsync_onto_a_row_in_another_container_also_changes_the_folder()
|
|
{
|
|
await SeedAsync("f1", ("inside", "Inside", 0));
|
|
await SeedAsync(null, ("mover", "Mover", 1));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var mover = vm.RootLists.Single();
|
|
var inside = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
|
|
|
|
await vm.ReorderAsync(mover, inside, placeBelow: false);
|
|
|
|
Assert.Empty(vm.RootLists);
|
|
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Children.OfType<ListNavItemViewModel>().Select(l => l.Name));
|
|
Assert.Equal("f1", mover.FolderId);
|
|
|
|
await using var db = NewContext();
|
|
var stored = await db.Lists.AsNoTracking().ToListAsync();
|
|
Assert.Equal("f1", stored.Single(l => l.Id == "mover").FolderId);
|
|
// sort_order is one sequence over the whole sidebar, folders included: f1 takes 0, its
|
|
// two children 1 and 2.
|
|
Assert.Equal(0, (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).SortOrder);
|
|
Assert.Equal(1, stored.Single(l => l.Id == "mover").SortOrder);
|
|
Assert.Equal(2, stored.Single(l => l.Id == "inside").SortOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DropAsync_reorders_folders_among_each_other_and_persists_it()
|
|
{
|
|
await SeedAsync("f1", folderSort: 0);
|
|
await SeedAsync("f2", folderSort: 1);
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var (first, second) = (vm.Folders[0], vm.Folders[1]);
|
|
|
|
await vm.DropAsync(second, first, placeBelow: false);
|
|
|
|
Assert.Equal([second, first], vm.Folders);
|
|
await using var db = NewContext();
|
|
var stored = await db.ListFolders.AsNoTracking().ToListAsync();
|
|
Assert.Equal(0, stored.Single(f => f.Id == second.Id).SortOrder);
|
|
Assert.Equal(1, stored.Single(f => f.Id == first.Id).SortOrder);
|
|
|
|
// And it survives a reload — the load path merges folders and lists by sort_order.
|
|
var reloaded = NewVm();
|
|
await reloaded.LoadAsync();
|
|
Assert.Equal([second.Id, first.Id], reloaded.Folders.Select(f => f.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DropAsync_puts_a_list_above_a_folder_at_the_top_level()
|
|
{
|
|
await SeedAsync("f1", ("inside", "Inside", 1));
|
|
await SeedAsync(null, ("mover", "Mover", 2));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var folder = vm.Folders[0];
|
|
var mover = vm.RootLists.Single();
|
|
|
|
await vm.DropAsync(mover, folder, placeBelow: false);
|
|
|
|
Assert.Equal(new object[] { mover, folder }, vm.TopLevel);
|
|
Assert.Null(mover.FolderId);
|
|
|
|
var reloaded = NewVm();
|
|
await reloaded.LoadAsync();
|
|
Assert.Equal(["user:mover", "f1"], reloaded.TopLevel.Select(i =>
|
|
i is ListNavItemViewModel l ? l.Id : ((ListFolderNavItemViewModel)i).Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DropAsync_nests_a_folder_beside_a_list_inside_another_folder()
|
|
{
|
|
// Folders now nest anywhere (drag-and-drop only, arbitrary depth) — dropping f2 next to a
|
|
// list that lives inside f1 moves f2 into f1's container alongside it.
|
|
await SeedAsync("f1", 0, ("inside", "Inside", 1));
|
|
await SeedAsync("f2", folderSort: 2);
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var f1 = vm.Folders.Single(f => f.Id == "f1");
|
|
var f2 = vm.Folders.Single(f => f.Id == "f2");
|
|
var inside = f1.Children.OfType<ListNavItemViewModel>().Single();
|
|
|
|
await vm.DropAsync(f2, inside, placeBelow: true);
|
|
|
|
Assert.Equal("f1", f2.ParentFolderId);
|
|
Assert.Equal(new object[] { inside, f2 }, f1.Children);
|
|
Assert.Single(vm.TopLevel);
|
|
|
|
await using var db = NewContext();
|
|
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DropAsync_ignores_dropping_a_folder_beside_a_target_in_its_own_subtree()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var top = vm.Folders.Single(f => f.Id == "top");
|
|
var child = vm.Folders.Single(f => f.Id == "child");
|
|
|
|
// Would require nesting "top" inside its own descendant "child" — silently ignored.
|
|
await vm.DropAsync(top, child, placeBelow: true);
|
|
|
|
Assert.Null(top.ParentFolderId);
|
|
Assert.Equal("top", child.ParentFolderId);
|
|
Assert.Single(vm.TopLevel);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToFolderAsync_nests_a_folder_into_another_folder_and_persists_it()
|
|
{
|
|
await SeedAsync("f1", folderSort: 0);
|
|
await SeedAsync("f2", folderSort: 1);
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var f1 = vm.Folders.Single(f => f.Id == "f1");
|
|
var f2 = vm.Folders.Single(f => f.Id == "f2");
|
|
|
|
await vm.MoveToFolderAsync(f2, f1);
|
|
|
|
Assert.Equal("f1", f2.ParentFolderId);
|
|
Assert.Single(vm.TopLevel);
|
|
Assert.Same(f2, Assert.Single(f1.Children));
|
|
|
|
await using var db = NewContext();
|
|
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveToFolderAsync_ignores_moving_a_folder_into_itself_or_a_descendant()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
|
|
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var top = vm.Folders.Single(f => f.Id == "top");
|
|
var child = vm.Folders.Single(f => f.Id == "child");
|
|
|
|
await vm.MoveToFolderAsync(top, top);
|
|
await vm.MoveToFolderAsync(top, child);
|
|
|
|
Assert.Null(top.ParentFolderId);
|
|
Assert.Equal("top", child.ParentFolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReorderAsync_within_a_folder_keeps_the_folder()
|
|
{
|
|
await SeedAsync("f1", ("a", "A", 0), ("b", "B", 1));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var children = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
|
|
var (a, b) = (children[0], children[1]);
|
|
|
|
await vm.ReorderAsync(b, a, placeBelow: false);
|
|
|
|
var reordered = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
|
|
Assert.Equal(["B", "A"], reordered.Select(l => l.Name));
|
|
Assert.All(reordered, l => Assert.Equal("f1", l.FolderId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ToggleFolder_flips_and_persists_the_collapse_state()
|
|
{
|
|
await SeedAsync("f1", ("inside", "Inside", 0));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var folder = vm.Folders[0];
|
|
Assert.False(folder.IsCollapsed);
|
|
|
|
vm.ToggleFolderCommand.Execute(folder);
|
|
await Task.Delay(100);
|
|
|
|
Assert.True(folder.IsCollapsed);
|
|
await using var db = NewContext();
|
|
Assert.True((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed);
|
|
|
|
// And the state survives a reload.
|
|
var reloaded = NewVm();
|
|
await reloaded.LoadAsync();
|
|
Assert.True(reloaded.Folders[0].IsCollapsed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ToggleFolder_rapid_double_click_both_toggles_land()
|
|
{
|
|
// Bug F1 (docs/test-session-2026-09-16.md): the command used to be an async
|
|
// [RelayCommand], so AsyncRelayCommand's own CanExecute lock swallowed the second click
|
|
// while the first one's DB write was still in flight. Firing two toggles back-to-back
|
|
// (no await between them) reproduces exactly that window.
|
|
await SeedAsync("f1", ("inside", "Inside", 0));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var folder = vm.Folders[0];
|
|
Assert.False(folder.IsCollapsed);
|
|
|
|
vm.ToggleFolderCommand.Execute(folder);
|
|
vm.ToggleFolderCommand.Execute(folder);
|
|
await Task.Delay(100);
|
|
|
|
// Two flips land back on the original (visible) state...
|
|
Assert.False(folder.IsCollapsed);
|
|
// ...and the DB ends up agreeing with it, not with the stale mid-flight value.
|
|
await using var db = NewContext();
|
|
Assert.False((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemoveFromFolder_command_is_the_context_menu_route_out()
|
|
{
|
|
await SeedAsync("f1", ("inside", "Inside", 0));
|
|
|
|
var vm = NewVm();
|
|
await vm.LoadAsync();
|
|
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
|
|
|
|
await vm.RemoveFromFolderCommand.ExecuteAsync(row);
|
|
|
|
Assert.Null(row.FolderId);
|
|
Assert.Single(vm.RootLists);
|
|
}
|
|
}
|