feat(ui): Listen und Ordner in einer gemischten Sidebar-Reihenfolge

Die oberste Sidebar-Ebene ist jetzt eine gemischte TopLevel-Collection, so
dass eine Liste auch oberhalb eines Ordners stehen kann. DropAsync deckt
alle vier Quelle/Ziel-Kombinationen ab, ReorderSidebarAsync numeriert die
gesamte sichtbare Reihenfolge ueber lists und list_folders hinweg.
This commit is contained in:
mika kuns
2026-09-10 15:40:10 +02:00
parent 1770b67713
commit bfb1c6523c
9 changed files with 472 additions and 191 deletions
@@ -209,12 +209,19 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// <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();
/// <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>Sidebar folders in order — a projection of <see cref="TopLevel"/>, not a second
/// collection to keep in sync.</summary>
public IReadOnlyList<ListFolderNavItemViewModel> Folders =>
TopLevel.OfType<ListFolderNavItemViewModel>().ToList();
/// <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;
@@ -291,8 +298,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Items.Clear();
SmartLists.Clear();
UserLists.Clear();
Folders.Clear();
RootLists.Clear();
TopLevel.Clear();
var smart = new[]
{
@@ -314,14 +320,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
// Folders and top-level lists share one sort_order space, so both go into a single
// ordered bucket and get merged by it — that's what interleaves them.
var topLevel = new List<(int Sort, object Item)>();
var foldersById = new Dictionary<string, ListFolderNavItemViewModel>();
foreach (var f in await lists.GetFoldersAsync(ct))
Folders.Add(new ListFolderNavItemViewModel
{
var folder = new ListFolderNavItemViewModel
{
Id = f.Id,
Name = f.Name,
IconKey = f.Icon ?? ListFolderIcon,
IsCollapsed = f.IsCollapsed,
});
};
foldersById[f.Id] = folder;
topLevel.Add((f.SortOrder, folder));
}
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
foreach (var l in await lists.GetAllAsync(ct))
@@ -343,8 +357,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
};
Items.Add(item);
UserLists.Add(item);
ContainerOf(item).Add(item);
if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner))
owner.Lists.Add(item);
else
topLevel.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.
foreach (var (_, item) in topLevel.OrderBy(t => t.Sort))
TopLevel.Add(item);
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
@@ -431,7 +453,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
};
Items.Add(item);
UserLists.Add(item);
RootLists.Add(item);
TopLevel.Add(item);
SelectedList = item;
RecomputeHasNoLinkedRepo();
@@ -462,55 +484,83 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
r.DropHintAbove = false;
r.DropHintBelow = false;
}
foreach (var f in Folders) f.IsDropTarget = false;
foreach (var f in Folders)
{
f.IsDropTarget = false;
f.DropHintAbove = false;
f.DropHintBelow = false;
}
}
public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
/// <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)
{
foreach (var r in UserLists)
ClearDropHints();
switch (target)
{
var isTarget = ReferenceEquals(r, target);
r.DropHintAbove = isTarget && !placeBelow;
r.DropHintBelow = isTarget && placeBelow;
case ListNavItemViewModel row:
row.DropHintAbove = !placeBelow;
row.DropHintBelow = placeBelow;
break;
case ListFolderNavItemViewModel folder:
folder.DropHintAbove = !placeBelow;
folder.DropHintBelow = placeBelow;
break;
}
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);
ClearDropHints();
if (target is not null) target.IsDropTarget = true;
}
/// <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;
/// <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)?.Lists ?? (System.Collections.IList)TopLevel;
/// <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"/> 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.
/// 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 list 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 a list out of a folder. Folders live at the top level only, so a folder
/// dropped onto a row inside a folder is ignored rather than nested.
/// </summary>
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow)
public async Task DropAsync(object source, object target, bool placeBelow)
{
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
if (ReferenceEquals(source, target)) return;
if (target is ListNavItemViewModel { Kind: not ListKind.User }) return;
var folderChanged = source.FolderId != target.FolderId;
if (folderChanged)
if (source is ListFolderNavItemViewModel folder)
{
ContainerOf(source).Remove(source);
source.FolderId = target.FolderId;
ContainerOf(source).Add(source);
if (target is ListNavItemViewModel { FolderId: not null }) return;
MoveWithinCollection(TopLevel, folder, target, placeBelow);
await PersistOrderAsync(null);
return;
}
MoveWithinCollection(ContainerOf(source), source, target, placeBelow);
await PersistOrderAsync(folderChanged ? source : null);
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
// Next to a folder *header* means top level, not inside it — MoveToFolderAsync is the
// drop-into-the-folder path.
var targetFolderId = target is ListNavItemViewModel t ? t.FolderId : 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 list to a folder (or to the top level when <paramref name="folder"/> is
@@ -533,10 +583,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// </summary>
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow)
{
var orderedIds = Folders.SelectMany(f => f.Lists)
.Concat(RootLists)
.Select(i => RawId(i.Id))
.ToList();
var ordered = new List<(string Id, bool IsFolder)>();
foreach (var item in TopLevel)
{
switch (item)
{
case ListFolderNavItemViewModel f:
ordered.Add((f.Id, true));
ordered.AddRange(f.Lists.Select(l => (RawId(l.Id), false)));
break;
case ListNavItemViewModel l:
ordered.Add((RawId(l.Id), false));
break;
}
}
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
@@ -546,7 +606,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var lists = new ListRepository(ctx);
if (movedRow is not null)
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
await lists.ReorderAsync(orderedIds);
await lists.ReorderSidebarAsync(ordered);
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
@@ -601,9 +661,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
rowId.StartsWith("user:", StringComparison.Ordinal) ? rowId["user:".Length..] : rowId;
private static void MoveWithinCollection(
ObservableCollection<ListNavItemViewModel> coll,
ListNavItemViewModel source,
ListNavItemViewModel target,
System.Collections.IList coll,
object source,
object target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
@@ -616,7 +676,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
if (finalIdx == srcIdx) return;
coll.Move(srcIdx, finalIdx);
coll.RemoveAt(srcIdx);
coll.Insert(finalIdx, source);
}
partial void OnSelectedListChanged(ListNavItemViewModel? value)
@@ -717,14 +778,21 @@ 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);
// 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.Lists.Insert(
containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx,
item);
}
else TopLevel.Add(item);
RecomputeHasNoLinkedRepo();
}
}