feat(lists): allow nested sidebar folders via drag and drop

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>
This commit is contained in:
Mika Kuns
2026-09-28 13:31:26 +02:00
co-authored by Claude Sonnet 5
parent 352aaa525b
commit 0dac047c46
16 changed files with 1610 additions and 88 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Lists`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. `Folders`/`RootLists` are `OfType` **projections** of it, not collections to keep in sync. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row)` resolves which collection renders it (non-generic `IList`, since the top level is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same list template by walking up to the UserControl. A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of its children. `DropAsync(source, target, placeBelow)` handles all four source/target combinations and doubles as the move: a drop position implies a container, so dropping a list onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`. A folder dropped onto a row inside a folder is ignored; folders never nest. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`, one `sort_order` sequence spanning **both tables**. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Children`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. Folders nest arbitrarily deep — a folder's `Children` mixes lists and subfolders the same way `TopLevel` mixes folders and folder-less lists, via `ParentFolderId`. `Folders` is a **recursive** `OfType` projection over the whole tree (not just the top level); `RootLists` stays a `TopLevel`-only projection. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row-or-folder)` resolves which collection renders it (non-generic `IList`, since every container is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same templates recursively by walking up to the UserControl (which is also what indents nested folders: each level adds its template's fixed margin). A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of all descendant lists, recursive. `DropAsync(source, target, placeBelow)` handles list and folder sources against any target and doubles as the move: a drop position implies a container, so dropping a row onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`/`ParentFolderId`. A folder dropped onto or next to a target inside its own subtree is silently ignored (self/descendant guard), never nested into itself. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead; also takes a folder source now); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`/`SetFolderParentAsync`, one `sort_order` sequence spanning **both tables**, walked depth-first. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
@@ -6,15 +6,18 @@ 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.
/// toggles <see cref="IsCollapsed"/> instead of changing the selection. Folders nest arbitrarily
/// deep via <see cref="ParentFolderId"/>; <see cref="Children"/> mixes lists and subfolders, the
/// same way <see cref="ListsIslandViewModel.TopLevel"/> mixes folders and folder-less lists.
/// </summary>
public sealed partial class ListFolderNavItemViewModel : ViewModelBase
{
public required string Id { get; init; }
public string? ParentFolderId { get; set; }
[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.
// Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
[ObservableProperty] private int _count;
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
[ObservableProperty] private bool _isDropTarget;
@@ -23,5 +26,5 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
public ObservableCollection<ListNavItemViewModel> Lists { get; } = new();
public ObservableCollection<object> Children { get; } = new();
}
@@ -215,10 +215,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// 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>Every sidebar folder, at any depth — a recursive projection of
/// <see cref="TopLevel"/>, not a second collection to keep in sync.</summary>
public IReadOnlyList<ListFolderNavItemViewModel> Folders => AllFolders(TopLevel).ToList();
private static IEnumerable<ListFolderNavItemViewModel> AllFolders(IEnumerable<object> items)
{
foreach (var item in items)
{
if (item is not ListFolderNavItemViewModel folder) continue;
yield return folder;
foreach (var descendant in AllFolders(folder.Children))
yield return descendant;
}
}
/// <summary>The user lists that sit outside any folder — likewise a projection.</summary>
public IReadOnlyList<ListNavItemViewModel> RootLists =>
TopLevel.OfType<ListNavItemViewModel>().ToList();
@@ -320,10 +330,18 @@ 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)>();
// Every container (the top level, and each folder's children) gets its own ordered
// bucket keyed by sort_order, since folders and lists at any depth share one sort_order
// space. Buckets are keyed by parent folder id; null (via topLevelBucket) is the top level.
var topLevelBucket = new List<(int Sort, object Item)>();
var childBuckets = new Dictionary<string, List<(int Sort, object Item)>>();
var foldersById = new Dictionary<string, ListFolderNavItemViewModel>();
List<(int Sort, object Item)> BucketFor(string? parentFolderId) =>
parentFolderId is { } pid && foldersById.ContainsKey(pid)
? childBuckets.TryGetValue(pid, out var b) ? b : childBuckets[pid] = new()
: topLevelBucket;
foreach (var f in await lists.GetFoldersAsync(ct))
{
var folder = new ListFolderNavItemViewModel
@@ -332,9 +350,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Name = f.Name,
IconKey = f.Icon ?? ListFolderIcon,
IsCollapsed = f.IsCollapsed,
ParentFolderId = f.ParentFolderId,
};
foldersById[f.Id] = folder;
topLevel.Add((f.SortOrder, folder));
// Parent always precedes its children in sort_order (PersistOrderAsync walks
// depth-first), so the parent bucket already exists by the time a child is seen.
BucketFor(f.ParentFolderId).Add((f.SortOrder, folder));
}
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -357,16 +378,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
};
Items.Add(item);
UserLists.Add(item);
if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner))
owner.Lists.Add(item);
else
topLevel.Add((l.SortOrder, item));
BucketFor(item.FolderId).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);
void Materialize(List<(int Sort, object Item)> bucket, System.Collections.IList target)
{
foreach (var (_, item) in bucket.OrderBy(t => t.Sort))
{
target.Add(item);
if (item is ListFolderNavItemViewModel folder && childBuckets.TryGetValue(folder.Id, out var childBucket))
Materialize(childBucket, folder.Children);
}
}
Materialize(topLevelBucket, TopLevel);
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
@@ -517,10 +543,26 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (target is not null) target.IsDropTarget = true;
}
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or
/// <summary>The collection a row is rendered in: its folder's <c>Children</c>, or
/// <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;
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Children ?? (System.Collections.IList)TopLevel;
/// <summary>The collection a folder is rendered in: its parent's <c>Children</c>, or
/// <see cref="TopLevel"/> when it has none.</summary>
public System.Collections.IList ContainerOf(ListFolderNavItemViewModel folder) =>
Folders.FirstOrDefault(f => f.Id == folder.ParentFolderId)?.Children ?? (System.Collections.IList)TopLevel;
/// <summary>True when <paramref name="candidateId"/> is <paramref name="folder"/> itself or
/// nested anywhere under it — the guard against dropping a folder into its own subtree.</summary>
private static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, string? candidateId)
{
if (candidateId is null) return false;
if (candidateId == folder.Id) return true;
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
if (IsSelfOrDescendant(child, candidateId)) return true;
return false;
}
/// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary>
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
@@ -528,10 +570,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// <summary>
/// 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
/// position. Since a drop position implies a container, dropping a row 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.
/// only way to drag something out of a folder. A folder dropped next to a target inside its own
/// subtree is ignored rather than nested into itself.
/// </summary>
public async Task DropAsync(object source, object target, bool placeBelow)
{
@@ -540,17 +582,43 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (source is ListFolderNavItemViewModel folder)
{
if (target is ListNavItemViewModel { FolderId: not null }) return;
MoveWithinCollection(TopLevel, folder, target, placeBelow);
await PersistOrderAsync(null);
var newParentId = target switch
{
ListFolderNavItemViewModel tf => tf.ParentFolderId,
ListNavItemViewModel tl => tl.FolderId,
_ => null,
};
if (IsSelfOrDescendant(folder, newParentId)) return;
var targetContainer = target switch
{
ListFolderNavItemViewModel tf => ContainerOf(tf),
ListNavItemViewModel tl => ContainerOf(tl),
_ => (System.Collections.IList)TopLevel,
};
var parentChanged = folder.ParentFolderId != newParentId;
if (parentChanged)
{
ContainerOf(folder).Remove(folder);
folder.ParentFolderId = newParentId;
targetContainer.Add(folder);
}
MoveWithinCollection(targetContainer, folder, target, placeBelow);
await PersistOrderAsync(folder);
return;
}
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;
// Next to a folder *header* means that folder's own container, not inside it —
// MoveToFolderAsync is the drop-into-the-folder path.
var targetFolderId = target switch
{
ListNavItemViewModel t => t.FolderId,
ListFolderNavItemViewModel tf => tf.ParentFolderId,
_ => null,
};
var folderChanged = row.FolderId != targetFolderId;
if (folderChanged)
{
@@ -563,40 +631,58 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await PersistOrderAsync(folderChanged ? row : 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)
/// <summary>Appends a row (list or folder) to <paramref name="folder"/> (or to the top level
/// when null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty
/// folders. A folder source dropped into itself or one of its own subfolders is ignored.</summary>
public async Task MoveToFolderAsync(object source, ListFolderNavItemViewModel? folder)
{
if (source.Kind != ListKind.User) return;
if (source.FolderId == folder?.Id) return;
if (source is ListFolderNavItemViewModel sourceFolder)
{
if (ReferenceEquals(sourceFolder, folder)) return;
if (IsSelfOrDescendant(sourceFolder, folder?.Id)) return;
if (sourceFolder.ParentFolderId == folder?.Id) return;
ContainerOf(source).Remove(source);
source.FolderId = folder?.Id;
ContainerOf(source).Add(source);
await PersistOrderAsync(source);
ContainerOf(sourceFolder).Remove(sourceFolder);
sourceFolder.ParentFolderId = folder?.Id;
ContainerOf(sourceFolder).Add(sourceFolder);
await PersistOrderAsync(sourceFolder);
return;
}
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
if (row.FolderId == folder?.Id) return;
ContainerOf(row).Remove(row);
row.FolderId = folder?.Id;
ContainerOf(row).Add(row);
await PersistOrderAsync(row);
}
/// <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>.
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence,
/// walked depth-first (a folder, then its children, at any depth) and, when a row changed
/// container, its new <c>folder_id</c>/<c>parent_folder_id</c>.
/// </summary>
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow)
private async Task PersistOrderAsync(object? movedItem)
{
var ordered = new List<(string Id, bool IsFolder)>();
foreach (var item in TopLevel)
void Walk(IEnumerable<object> items)
{
switch (item)
foreach (var item in items)
{
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;
switch (item)
{
case ListFolderNavItemViewModel f:
ordered.Add((f.Id, true));
Walk(f.Children);
break;
case ListNavItemViewModel l:
ordered.Add((RawId(l.Id), false));
break;
}
}
}
Walk(TopLevel);
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
@@ -604,8 +690,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
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);
switch (movedItem)
{
case ListNavItemViewModel movedRow:
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
break;
case ListFolderNavItemViewModel movedFolder:
await lists.SetFolderParentAsync(movedFolder.Id, movedFolder.ParentFolderId);
break;
}
await lists.ReorderSidebarAsync(ordered);
ok = true;
}
@@ -671,7 +764,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
private void RecomputeFolderCounts()
{
foreach (var f in Folders) f.Count = f.Lists.Sum(l => l.Count);
foreach (var item in TopLevel)
if (item is ListFolderNavItemViewModel f) ComputeFolderCount(f);
}
private static int ComputeFolderCount(ListFolderNavItemViewModel folder)
{
var sum = 0;
foreach (var child in folder.Children)
sum += child switch
{
ListNavItemViewModel l => l.Count,
ListFolderNavItemViewModel f => ComputeFolderCount(f),
_ => 0,
};
folder.Count = sum;
return sum;
}
private static string RawId(string rowId) =>
@@ -805,8 +913,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
.Select(l => l.Id)
.ToList()
.IndexOf(entity.Id);
folder.Lists.Insert(
containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx,
folder.Children.Insert(
containerIdx < 0 || containerIdx > folder.Children.Count ? folder.Children.Count : containerIdx,
item);
}
else TopLevel.Add(item);
@@ -139,7 +139,7 @@
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding Lists}"
<ItemsControl ItemsSource="{Binding Children}"
IsVisible="{Binding !IsCollapsed}"
Margin="14,0,0,0"/>
</StackPanel>
@@ -142,18 +142,21 @@ public partial class ListsIslandView : UserControl
private enum FolderDrop { None, Into, Beside }
/// <summary>
/// What a drop on a folder header means. For a list the middle half moves it *into* the folder
/// What a drop on a folder header means: the middle half moves the source *into* the folder
/// (the only route into a collapsed or empty one) while the outer quarters insert next to the
/// folder at the top level; a folder source has no "into" — folders never nest — so it splits
/// in half.
/// folder in its own container. A folder dropped onto itself or one of its own subfolders has
/// no valid "into" or "beside" and is rejected.
/// </summary>
private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop(
object source, ListFolderNavItemViewModel folder, double y, double height)
{
if (source is ListFolderNavItemViewModel dragged)
return ReferenceEquals(dragged, folder)
? (FolderDrop.None, false)
: (FolderDrop.Beside, y > height / 2);
{
if (IsSelfOrDescendant(dragged, folder)) return (FolderDrop.None, false);
if (y < height * 0.25) return (FolderDrop.Beside, false);
if (y > height * 0.75) return (FolderDrop.Beside, true);
return dragged.ParentFolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
}
if (source is not ListNavItemViewModel row) return (FolderDrop.None, false);
if (y < height * 0.25) return (FolderDrop.Beside, false);
@@ -161,6 +164,14 @@ public partial class ListsIslandView : UserControl
return row.FolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
}
private static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, ListFolderNavItemViewModel candidate)
{
if (ReferenceEquals(folder, candidate)) return true;
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
if (IsSelfOrDescendant(child, candidate)) return true;
return false;
}
private void OnFolderDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
@@ -201,8 +212,8 @@ public partial class ListsIslandView : UserControl
var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height);
vm.ClearDropHints();
if (kind == FolderDrop.Into && source is ListNavItemViewModel row)
await vm.MoveToFolderAsync(row, folder);
if (kind == FolderDrop.Into)
await vm.MoveToFolderAsync(source, folder);
else if (kind == FolderDrop.Beside)
await vm.DropAsync(source, folder, placeBelow);
}
@@ -224,8 +235,6 @@ public partial class ListsIslandView : UserControl
var source = DragSource(vm, e);
if (source is null || ReferenceEquals(source, target)) { Reject(vm, e); return; }
// A folder can only be dropped at the top level, so a row inside a folder is no target.
if (source is ListFolderNavItemViewModel && target.FolderId is not null) { Reject(vm, e); return; }
// Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line.
object hintTarget = target;
@@ -251,7 +260,6 @@ public partial class ListsIslandView : UserControl
var source = DragSource(vm, e);
if (source is null || ReferenceEquals(source, target)) return;
if (source is ListFolderNavItemViewModel && target.FolderId is not null) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
vm.ClearDropHints();