feat(claude-do): merge Ordner in Ordner erlauben (beliebig tief, nur per Drag & Dro
ClaudeDo-Task: dc3d5f8cf9e74821ba99b18e443fe5f5
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1152
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,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)
|
||||
{
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user