feat(ui): collapsible folders for the list sidebar

Folders render above the top-level lists, each with its own collapsible
children; tapping a folder header folds it instead of selecting it.
Lists move in by dragging onto a folder header (or onto a row inside it)
and out by dropping between top-level rows or via the context menu.

Creating a folder is a toggle in the New-list modal, not a third sidebar
button: it reduces the modal to name + icon and writes a folder row
instead of the list row the create flow pre-created.
This commit is contained in:
mika kuns
2026-09-10 08:29:56 +02:00
parent 13e860f06b
commit 25c9a619dc
9 changed files with 522 additions and 95 deletions
@@ -26,6 +26,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// 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;
@@ -202,7 +205,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
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>Sidebar folders, rendered above the top-level lists.</summary>
public ObservableCollection<ListFolderNavItemViewModel> Folders { get; } = new();
/// <summary>The user lists that sit outside any folder. Together with
/// <see cref="Folders"/> this is what the sidebar binds to; <see cref="UserLists"/> stays
/// the flat superset.</summary>
public ObservableCollection<ListNavItemViewModel> RootLists { get; } = new();
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private ListNavItemViewModel? _selectedList;
@@ -279,6 +290,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Items.Clear();
SmartLists.Clear();
UserLists.Clear();
Folders.Clear();
RootLists.Clear();
var smart = new[]
{
@@ -299,6 +312,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var ticketsConfigured = await IsTicketsConfiguredAsync();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
foreach (var f in await lists.GetFoldersAsync(ct))
Folders.Add(new ListFolderNavItemViewModel
{
Id = f.Id,
Name = f.Name,
IconKey = f.Icon ?? ListFolderIcon,
IsCollapsed = f.IsCollapsed,
});
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
foreach (var l in await lists.GetAllAsync(ct))
{
@@ -315,9 +338,13 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
IsManual = l.IsManual,
FindingsTracked = l.FindingsTracked,
HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0,
// A folder_id pointing at a folder that isn't there (shouldn't happen — the FK
// sets it null on delete) would otherwise hide the list entirely.
FolderId = Folders.Any(f => f.Id == l.FolderId) ? l.FolderId : null,
};
Items.Add(item);
UserLists.Add(item);
ContainerOf(item).Add(item);
}
ok = true;
}
@@ -365,6 +392,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var filter = _filters.Resolve(item.Id);
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
}
RecomputeFolderCounts();
ok = true;
}
catch (OperationCanceledException) { throw; }
@@ -403,6 +432,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
};
Items.Add(item);
UserLists.Add(item);
RootLists.Add(item);
SelectedList = item;
RecomputeHasNoLinkedRepo();
@@ -410,7 +440,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
// 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)
{
@@ -432,6 +463,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
r.DropHintAbove = false;
r.DropHintBelow = false;
}
foreach (var f in Folders) f.IsDropTarget = false;
}
public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
@@ -442,28 +474,133 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
r.DropHintAbove = isTarget && !placeBelow;
r.DropHintBelow = isTarget && placeBelow;
}
foreach (var f in Folders) f.IsDropTarget = false;
}
public void SetFolderDropTarget(ListFolderNavItemViewModel? target)
{
foreach (var r in UserLists)
{
r.DropHintAbove = false;
r.DropHintBelow = false;
}
foreach (var f in Folders) f.IsDropTarget = ReferenceEquals(f, target);
}
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or
/// <see cref="RootLists"/> when it has no folder.</summary>
public ObservableCollection<ListNavItemViewModel> ContainerOf(ListNavItemViewModel row) =>
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? RootLists;
/// <summary>
/// Drops <paramref name="source"/> at <paramref name="target"/>'s position. Since a drop
/// position implies a container, a drag onto a row in a different folder (or onto a top-level
/// row) also *moves* the list there — that's the only way to drag a list out of a folder.
/// </summary>
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow)
{
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
if (ReferenceEquals(source, target)) return;
MoveWithinCollection(UserLists, source, target, placeBelow);
var folderChanged = source.FolderId != target.FolderId;
if (folderChanged)
{
ContainerOf(source).Remove(source);
source.FolderId = target.FolderId;
ContainerOf(source).Add(source);
}
MoveWithinCollection(ContainerOf(source), source, target, placeBelow);
await PersistOrderAsync(folderChanged ? source : null);
}
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is
/// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders.</summary>
public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder)
{
if (source.Kind != ListKind.User) return;
if (source.FolderId == folder?.Id) return;
ContainerOf(source).Remove(source);
source.FolderId = folder?.Id;
ContainerOf(source).Add(source);
await PersistOrderAsync(source);
}
/// <summary>
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence
/// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new
/// <c>folder_id</c>.
/// </summary>
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow)
{
var orderedIds = Folders.SelectMany(f => f.Lists)
.Concat(RootLists)
.Select(i => RawId(i.Id))
.ToList();
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
if (movedRow is not null)
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
await lists.ReorderAsync(orderedIds);
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);
}
[RelayCommand]
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
{
if (folder is null) return;
folder.IsCollapsed = !folder.IsCollapsed;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
}
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 f in Folders) f.Count = f.Lists.Sum(l => l.Count);
}
private static string RawId(string rowId) =>
rowId.StartsWith("user:", StringComparison.Ordinal) ? rowId["user:".Length..] : rowId;
private static void MoveWithinCollection(
ObservableCollection<ListNavItemViewModel> coll,
ListNavItemViewModel source,
@@ -570,6 +707,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
IsManual = entity.IsManual,
FindingsTracked = entity.FindingsTracked,
HasTicketProject = hasTicketProject,
FolderId = Folders.Any(f => f.Id == entity.FolderId) ? entity.FolderId : null,
};
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -579,6 +717,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
UserLists.Insert(insertAt, item);
Items.Insert(SmartLists.Count + insertAt, item);
// Same DB-order position, but counted within the row's own container.
var container = ContainerOf(item);
var containerIdx = ordered
.Where(l => l.FolderId == item.FolderId)
.Select(l => l.Id)
.ToList()
.IndexOf(entity.Id);
container.Insert(containerIdx < 0 || containerIdx > container.Count ? container.Count : containerIdx, item);
RecomputeHasNoLinkedRepo();
}
}