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:
@@ -0,0 +1,23 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
/// <summary>
|
||||
/// A sidebar folder row. Not a <see cref="ListNavItemViewModel"/> and not a
|
||||
/// <see cref="ListKind"/> — a folder holds no tasks and is never selectable, so tapping it
|
||||
/// toggles <see cref="IsCollapsed"/> instead of changing the selection.
|
||||
/// </summary>
|
||||
public sealed partial class ListFolderNavItemViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _iconKey = "Folder";
|
||||
[ObservableProperty] private bool _isCollapsed;
|
||||
// Sum of the child lists' counts — a collapsed folder still shows what's inside it.
|
||||
[ObservableProperty] private int _count;
|
||||
// Set while a dragged list row hovers over this folder header (move-into-folder target).
|
||||
[ObservableProperty] private bool _isDropTarget;
|
||||
|
||||
public ObservableCollection<ListNavItemViewModel> Lists { get; } = new();
|
||||
}
|
||||
@@ -26,4 +26,6 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isTaskDropTarget;
|
||||
// Settable (not init) so a list-settings icon change refreshes the row in place.
|
||||
[ObservableProperty] private string? _iconKey;
|
||||
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
|
||||
[ObservableProperty] private string? _folderId;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user