feat(claude-do): merge Ordner in Ordner erlauben (beliebig tief, nur per Drag & Dro

ClaudeDo-Task: dc3d5f8cf9e74821ba99b18e443fe5f5
This commit is contained in:
Mika Kuns
2026-09-28 13:37:19 +02:00
16 changed files with 1610 additions and 88 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, FolderId
- `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP.
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir, **one level** (folders never nest). Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, ParentFolderId, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir. `ParentFolderId` (self-FK, `ON DELETE SET NULL`) lets folders nest arbitrarily deep — drag-and-drop only, no UI to pick a parent otherwise. Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section.
- ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one.
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
@@ -17,7 +17,14 @@ public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolder
builder.Property(f => f.Icon).HasColumnName("icon");
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
builder.Property(f => f.ParentFolderId).HasColumnName("parent_folder_id");
builder.HasIndex(f => f.SortOrder).HasDatabaseName("idx_list_folders_sort");
builder.HasIndex(f => f.ParentFolderId).HasDatabaseName("idx_list_folders_parent");
builder.HasOne<ListFolderEntity>()
.WithMany()
.HasForeignKey(f => f.ParentFolderId)
.OnDelete(DeleteBehavior.SetNull);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddNestedListFolders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "parent_folder_id",
table: "list_folders",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "idx_list_folders_parent",
table: "list_folders",
column: "parent_folder_id");
migrationBuilder.AddForeignKey(
name: "FK_list_folders_list_folders_parent_folder_id",
table: "list_folders",
column: "parent_folder_id",
principalTable: "list_folders",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_list_folders_list_folders_parent_folder_id",
table: "list_folders");
migrationBuilder.DropIndex(
name: "idx_list_folders_parent",
table: "list_folders");
migrationBuilder.DropColumn(
name: "parent_folder_id",
table: "list_folders");
}
}
}
@@ -389,6 +389,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("ParentFolderId")
.HasColumnType("TEXT")
.HasColumnName("parent_folder_id");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -397,6 +401,9 @@ namespace ClaudeDo.Data.Migrations
b.HasKey("Id");
b.HasIndex("ParentFolderId")
.HasDatabaseName("idx_list_folders_parent");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_list_folders_sort");
@@ -1027,6 +1034,14 @@ namespace ClaudeDo.Data.Migrations
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null)
.WithMany()
.HasForeignKey("ParentFolderId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
+4 -1
View File
@@ -4,7 +4,7 @@ namespace ClaudeDo.Data.Models;
/// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on
/// <see cref="ListEntity"/>: every place that enumerates lists (MCP list_task_lists, "move to
/// list", the queue picker, repo import) would otherwise have to filter folders out.
/// One level only — a folder holds lists, never other folders.
/// Folders nest arbitrarily deep via <see cref="ParentFolderId"/>.
/// </summary>
public sealed class ListFolderEntity
{
@@ -17,4 +17,7 @@ public sealed class ListFolderEntity
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
// already being read for the folder itself.
public bool IsCollapsed { get; set; }
// Parent folder, or null for a top-level folder. Self-FK, ON DELETE SET NULL — deleting a
// folder releases its subfolders to the top level, same as it does for its lists.
public string? ParentFolderId { get; set; }
}
@@ -135,6 +135,14 @@ public sealed class ListRepository
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct);
}
/// <summary>Moves a folder under <paramref name="parentFolderId"/>, or to the top level when null.</summary>
public async Task SetFolderParentAsync(string folderId, string? parentFolderId, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.ParentFolderId, parentFolderId), ct);
}
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
{
+1 -1
View File
@@ -885,7 +885,7 @@
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen und Unterordner darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "stashAndMergeConfirm": "Das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.\nStashen, mergen und danach wiederherstellen?", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
+1 -1
View File
@@ -885,7 +885,7 @@
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists and subfolders inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "stashAndMergeConfirm": "The target working directory has uncommitted changes.\nStash them, merge, and restore them afterwards?", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" },
+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();
@@ -144,4 +144,49 @@ public sealed class ListFolderTests : IDisposable
Assert.Equal(["One", "Two"], folders.Select(f => f.Name));
Assert.Equal([0, 1], folders.Select(f => f.SortOrder));
}
[Fact]
public async Task SetFolderParentAsync_nests_a_folder_and_moves_it_back_out()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
}
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", "parent");
await using (var ctx = Open())
Assert.Equal("parent", (await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", null);
await using (var ctx = Open())
Assert.Null((await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
}
[Fact]
public async Task Deleting_a_folder_keeps_its_subfolder_and_moves_it_to_the_top_level()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
await repo.SetFolderParentAsync("child", "parent");
}
await using (var ctx = Open())
await new ListRepository(ctx).DeleteFolderAsync("parent");
await using (var ctx = Open())
{
var child = await ctx.ListFolders.AsNoTracking().SingleAsync();
Assert.Equal("child", child.Id);
Assert.Null(child.ParentFolderId);
}
}
}
@@ -88,12 +88,62 @@ public class ListsIslandFolderTests : IDisposable
await vm.LoadAsync();
var folder = Assert.Single(vm.Folders);
Assert.Equal("Inside", Assert.Single(folder.Lists).Name);
Assert.Equal("Inside", Assert.Single(folder.Children.OfType<ListNavItemViewModel>()).Name);
Assert.Equal("Outside", Assert.Single(vm.RootLists).Name);
// UserLists stays the flat superset — counts and row refreshes work over it.
Assert.Equal(2, vm.UserLists.Count);
}
[Fact]
public async Task LoadAsync_builds_an_arbitrarily_deep_folder_tree()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "mid", Name = "Mid", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 2, ParentFolderId = "mid", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "deep", Name = "Deep", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = Assert.Single(vm.Folders, f => f.ParentFolderId is null);
Assert.Equal("top", top.Id);
var mid = Assert.Single(top.Children.OfType<ListFolderNavItemViewModel>());
Assert.Equal("mid", mid.Id);
var leaf = Assert.Single(mid.Children.OfType<ListFolderNavItemViewModel>());
Assert.Equal("leaf", leaf.Id);
Assert.Equal("Deep", Assert.Single(leaf.Children.OfType<ListNavItemViewModel>()).Name);
// The flat recursive projection sees all three levels.
Assert.Equal(3, vm.Folders.Count);
}
[Fact]
public async Task Folder_count_sums_recursively_through_nested_subfolders()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "a", Name = "A", SortOrder = 2, FolderId = "top", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "b", Name = "B", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
await SeedTaskAsync("a", 1);
await SeedTaskAsync("b", 2);
await SeedTaskAsync("b", 3);
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
// 1 task directly in "a" (under top) + 2 tasks in "b" (under the nested "leaf").
Assert.Equal(3, top.Count);
Assert.Equal(2, vm.Folders.Single(f => f.Id == "leaf").Count);
}
[Fact]
public async Task Folder_count_is_the_sum_of_its_lists()
{
@@ -121,7 +171,7 @@ public class ListsIslandFolderTests : IDisposable
await vm.MoveToFolderAsync(mover, vm.Folders[0]);
Assert.Empty(vm.RootLists);
Assert.Equal(2, vm.Folders[0].Lists.Count);
Assert.Equal(2, vm.Folders[0].Children.Count);
Assert.Equal("f1", mover.FolderId);
await using var db = NewContext();
@@ -135,11 +185,11 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single();
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.MoveToFolderAsync(row, null);
Assert.Empty(vm.Folders[0].Lists);
Assert.Empty(vm.Folders[0].Children);
Assert.Same(row, Assert.Single(vm.RootLists));
Assert.Null(row.FolderId);
@@ -156,12 +206,12 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var mover = vm.RootLists.Single();
var inside = vm.Folders[0].Lists.Single();
var inside = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.ReorderAsync(mover, inside, placeBelow: false);
Assert.Empty(vm.RootLists);
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Lists.Select(l => l.Name));
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Children.OfType<ListNavItemViewModel>().Select(l => l.Name));
Assert.Equal("f1", mover.FolderId);
await using var db = NewContext();
@@ -221,20 +271,93 @@ public class ListsIslandFolderTests : IDisposable
}
[Fact]
public async Task DropAsync_never_nests_a_folder_in_a_folder()
public async Task DropAsync_nests_a_folder_beside_a_list_inside_another_folder()
{
// Folders now nest anywhere (drag-and-drop only, arbitrary depth) — dropping f2 next to a
// list that lives inside f1 moves f2 into f1's container alongside it.
await SeedAsync("f1", 0, ("inside", "Inside", 1));
await SeedAsync("f2", folderSort: 2);
var vm = NewVm();
await vm.LoadAsync();
var inside = vm.Folders[0].Lists.Single();
var f2 = vm.Folders[1];
var f1 = vm.Folders.Single(f => f.Id == "f1");
var f2 = vm.Folders.Single(f => f.Id == "f2");
var inside = f1.Children.OfType<ListNavItemViewModel>().Single();
await vm.DropAsync(f2, inside, placeBelow: true);
Assert.Equal(2, vm.TopLevel.Count);
Assert.Single(vm.Folders[0].Lists);
Assert.Equal("f1", f2.ParentFolderId);
Assert.Equal(new object[] { inside, f2 }, f1.Children);
Assert.Single(vm.TopLevel);
await using var db = NewContext();
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
}
[Fact]
public async Task DropAsync_ignores_dropping_a_folder_beside_a_target_in_its_own_subtree()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
var child = vm.Folders.Single(f => f.Id == "child");
// Would require nesting "top" inside its own descendant "child" — silently ignored.
await vm.DropAsync(top, child, placeBelow: true);
Assert.Null(top.ParentFolderId);
Assert.Equal("top", child.ParentFolderId);
Assert.Single(vm.TopLevel);
}
[Fact]
public async Task MoveToFolderAsync_nests_a_folder_into_another_folder_and_persists_it()
{
await SeedAsync("f1", folderSort: 0);
await SeedAsync("f2", folderSort: 1);
var vm = NewVm();
await vm.LoadAsync();
var f1 = vm.Folders.Single(f => f.Id == "f1");
var f2 = vm.Folders.Single(f => f.Id == "f2");
await vm.MoveToFolderAsync(f2, f1);
Assert.Equal("f1", f2.ParentFolderId);
Assert.Single(vm.TopLevel);
Assert.Same(f2, Assert.Single(f1.Children));
await using var db = NewContext();
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
}
[Fact]
public async Task MoveToFolderAsync_ignores_moving_a_folder_into_itself_or_a_descendant()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
var child = vm.Folders.Single(f => f.Id == "child");
await vm.MoveToFolderAsync(top, top);
await vm.MoveToFolderAsync(top, child);
Assert.Null(top.ParentFolderId);
Assert.Equal("top", child.ParentFolderId);
}
[Fact]
@@ -244,13 +367,14 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var a = vm.Folders[0].Lists[0];
var b = vm.Folders[0].Lists[1];
var children = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
var (a, b) = (children[0], children[1]);
await vm.ReorderAsync(b, a, placeBelow: false);
Assert.Equal(["B", "A"], vm.Folders[0].Lists.Select(l => l.Name));
Assert.All(vm.Folders[0].Lists, l => Assert.Equal("f1", l.FolderId));
var reordered = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
Assert.Equal(["B", "A"], reordered.Select(l => l.Name));
Assert.All(reordered, l => Assert.Equal("f1", l.FolderId));
}
[Fact]
@@ -308,7 +432,7 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single();
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.RemoveFromFolderCommand.ExecuteAsync(row);