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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user