From 0dac047c460616fc6ffb081e6ab922a8dc72dc6f Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 28 Sep 2026 13:31:26 +0200 Subject: [PATCH] feat(lists): allow nested sidebar folders via drag and drop Folders can now contain other folders, to arbitrary depth, via a self-referencing parent_folder_id (ON DELETE SET NULL, same pattern as lists.folder_id). Nesting/un-nesting is drag-and-drop only: dropping a folder onto another folder's header middle moves it in, the outer quarters or any top-level/in-folder drop insert it beside a target. Dropping a folder into itself or one of its own subfolders is silently ignored. Deleting a folder releases its lists and subfolders back to the top level, unchanged from the existing list behavior. ListFolderNavItemViewModel.Lists became Children (object, mixing lists and subfolders, mirroring TopLevel) so the existing implicit, type-matched DataTemplates render nested folders recursively without new XAML, and each nesting level gets its indent for free from the folder template's own margin. Folders/RootLists/ContainerOf/PersistOrderAsync/RecomputeFolderCounts now walk the tree instead of assuming one level. Co-Authored-By: Claude Sonnet 5 --- src/ClaudeDo.Data/CLAUDE.md | 2 +- .../ListFolderEntityConfiguration.cs | 7 + ...928111701_AddNestedListFolders.Designer.cs | 1152 +++++++++++++++++ .../20260928111701_AddNestedListFolders.cs | 49 + .../ClaudeDoDbContextModelSnapshot.cs | 15 + src/ClaudeDo.Data/Models/ListFolderEntity.cs | 5 +- .../Repositories/ListRepository.cs | 8 + src/ClaudeDo.Localization/locales/de.json | 2 +- src/ClaudeDo.Localization/locales/en.json | 2 +- src/ClaudeDo.Ui/CLAUDE.md | 2 +- .../Islands/ListFolderNavItemViewModel.cs | 9 +- .../Islands/ListsIslandViewModel.cs | 212 ++- .../Views/Islands/ListsIslandView.axaml | 2 +- .../Views/Islands/ListsIslandView.axaml.cs | 30 +- tests/ClaudeDo.Data.Tests/ListFolderTests.cs | 45 + .../ViewModels/ListsIslandFolderTests.cs | 156 ++- 16 files changed, 1610 insertions(+), 88 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260928111701_AddNestedListFolders.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260928111701_AddNestedListFolders.cs diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index beba0d51..d64a878b 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -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`) diff --git a/src/ClaudeDo.Data/Configuration/ListFolderEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListFolderEntityConfiguration.cs index 77c0e87f..f85098cc 100644 --- a/src/ClaudeDo.Data/Configuration/ListFolderEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListFolderEntityConfiguration.cs @@ -17,7 +17,14 @@ public class ListFolderEntityConfiguration : IEntityTypeConfiguration 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() + .WithMany() + .HasForeignKey(f => f.ParentFolderId) + .OnDelete(DeleteBehavior.SetNull); } } diff --git a/src/ClaudeDo.Data/Migrations/20260928111701_AddNestedListFolders.Designer.cs b/src/ClaudeDo.Data/Migrations/20260928111701_AddNestedListFolders.Designer.cs new file mode 100644 index 00000000..c9c6bb92 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260928111701_AddNestedListFolders.Designer.cs @@ -0,0 +1,1152 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260928111701_AddNestedListFolders")] + partial class AddNestedListFolders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("ListHandlerMaxHandoffs") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4) + .HasColumnName("list_handler_max_handoffs"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("NextTaskNumber") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("next_task_number"); + + b.Property("PowerUseEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("power_use_enabled"); + + b.Property("PowerUseHandoffPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(95) + .HasColumnName("power_use_handoff_pct"); + + b.Property("PowerUseWindowEnd") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_end"); + + b.Property("PowerUseWindowStart") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_start"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + AutoContinueOnUsageLimit = false, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + ListHandlerMaxHandoffs = 4, + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + NextTaskNumber = 1, + PowerUseEnabled = false, + PowerUseHandoffPct = 95, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("FromAgent") + .HasColumnType("INTEGER") + .HasColumnName("from_agent"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PowerUseAllowed") + .HasColumnType("INTEGER") + .HasColumnName("power_use_allowed"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketProjectId") + .HasColumnType("INTEGER") + .HasColumnName("ticket_project_id"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + b.Property("FolderId") + .HasColumnType("TEXT") + .HasColumnName("folder_id"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("FolderId") + .HasDatabaseName("idx_lists_folder"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsCollapsed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_collapsed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ParentFolderId") + .HasColumnType("TEXT") + .HasColumnName("parent_folder_id"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId") + .HasDatabaseName("idx_list_folders_parent"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_list_folders_sort"); + + b.ToTable("list_folders", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b => + { + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("BriefPath") + .HasColumnType("TEXT") + .HasColumnName("brief_path"); + + b.Property("Cwd") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cwd"); + + b.Property("PredecessorSessionId") + .HasColumnType("TEXT") + .HasColumnName("predecessor_session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("SessionId"); + + b.HasIndex("StartedAt"); + + b.ToTable("mission_control_sessions", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ping") + .HasColumnName("action_kind"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.HasKey("TaskId", "DependsOnTaskId"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_task_dependencies_depends_on"); + + b.ToTable("task_dependencies", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("ExpectsNoDiff") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("expects_no_diff"); + + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketRef") + .HasColumnType("TEXT") + .HasColumnName("ticket_ref"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("UsageLimitAutoContinuedAt") + .HasColumnType("TEXT") + .HasColumnName("usage_limit_auto_continued_at"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("Number") + .IsUnique() + .HasDatabaseName("idx_tasks_number"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null) + .WithMany() + .HasForeignKey("FolderId") + .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") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany("Dependencies") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Dependencies"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260928111701_AddNestedListFolders.cs b/src/ClaudeDo.Data/Migrations/20260928111701_AddNestedListFolders.cs new file mode 100644 index 00000000..f47c59eb --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260928111701_AddNestedListFolders.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddNestedListFolders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + 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"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 3dce8922..255a4e5f 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -389,6 +389,10 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("name"); + b.Property("ParentFolderId") + .HasColumnType("TEXT") + .HasColumnName("parent_folder_id"); + b.Property("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") diff --git a/src/ClaudeDo.Data/Models/ListFolderEntity.cs b/src/ClaudeDo.Data/Models/ListFolderEntity.cs index 80d3f153..f7efdda5 100644 --- a/src/ClaudeDo.Data/Models/ListFolderEntity.cs +++ b/src/ClaudeDo.Data/Models/ListFolderEntity.cs @@ -4,7 +4,7 @@ namespace ClaudeDo.Data.Models; /// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on /// : 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 . /// 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; } } diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index 33d416cf..c5493ade 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -135,6 +135,14 @@ public sealed class ListRepository .ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct); } + /// Moves a folder under , or to the top level when null. + 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); + } + /// Moves a list into , or back to the top level when null. public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default) { diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 7bb9a73d..1a5b941d 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 0ae54c2b..e0bc986a 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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}" }, diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 2baddd38..f03f52f8 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -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`: `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`: `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. diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs index be123cbf..e90fc12e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs @@ -6,15 +6,18 @@ namespace ClaudeDo.Ui.ViewModels.Islands; /// /// A sidebar folder row. Not a and not a /// — a folder holds no tasks and is never selectable, so tapping it -/// toggles instead of changing the selection. +/// toggles instead of changing the selection. Folders nest arbitrarily +/// deep via ; mixes lists and subfolders, the +/// same way mixes folders and folder-less lists. /// 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 Lists { get; } = new(); + public ObservableCollection Children { get; } = new(); } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 22dbf370..95803af2 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -215,10 +215,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable /// This is what the view binds to; stays the flat superset. public ObservableCollection TopLevel { get; } = new(); - /// Sidebar folders in order — a projection of , not a second - /// collection to keep in sync. - public IReadOnlyList Folders => - TopLevel.OfType().ToList(); + /// Every sidebar folder, at any depth — a recursive projection of + /// , not a second collection to keep in sync. + public IReadOnlyList Folders => AllFolders(TopLevel).ToList(); + + private static IEnumerable AllFolders(IEnumerable 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; + } + } /// The user lists that sit outside any folder — likewise a projection. public IReadOnlyList RootLists => TopLevel.OfType().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>(); var foldersById = new Dictionary(); + + 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(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; } - /// The collection a row is rendered in: its folder's Lists, or + /// The collection a row is rendered in: its folder's Children, or /// when it has no folder. Non-generic because the top level is mixed. 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; + + /// The collection a folder is rendered in: its parent's Children, or + /// when it has none. + public System.Collections.IList ContainerOf(ListFolderNavItemViewModel folder) => + Folders.FirstOrDefault(f => f.Id == folder.ParentFolderId)?.Children ?? (System.Collections.IList)TopLevel; + + /// True when is itself or + /// nested anywhere under it — the guard against dropping a folder into its own subtree. + 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()) + if (IsSelfOrDescendant(child, candidateId)) return true; + return false; + } /// Kept for the list→list case; covers all four combinations. public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) => @@ -528,10 +570,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable /// /// Drops (a list row or a folder) at '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. /// 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); } - /// Appends a list to a folder (or to the top level when is - /// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders. - public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder) + /// Appends a row (list or folder) to (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. + 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); } /// - /// Writes the sidebar's visual order back as a single global sort_order sequence - /// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new - /// folder_id. + /// Writes the sidebar's visual order back as a single global sort_order sequence, + /// walked depth-first (a folder, then its children, at any depth) and, when a row changed + /// container, its new folder_id/parent_folder_id. /// - 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 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); diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index 1670c44c..a9358906 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -139,7 +139,7 @@ - diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs index c1002d47..dc1b4838 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs @@ -142,18 +142,21 @@ public partial class ListsIslandView : UserControl private enum FolderDrop { None, Into, Beside } /// - /// 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. /// 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()) + 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(); diff --git a/tests/ClaudeDo.Data.Tests/ListFolderTests.cs b/tests/ClaudeDo.Data.Tests/ListFolderTests.cs index 39a350ac..aafb6318 100644 --- a/tests/ClaudeDo.Data.Tests/ListFolderTests.cs +++ b/tests/ClaudeDo.Data.Tests/ListFolderTests.cs @@ -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); + } + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs index 87bb24a5..98e2e2b3 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs @@ -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()).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()); + Assert.Equal("mid", mid.Id); + var leaf = Assert.Single(mid.Children.OfType()); + Assert.Equal("leaf", leaf.Id); + Assert.Equal("Deep", Assert.Single(leaf.Children.OfType()).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().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().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().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().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().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().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().Single(); await vm.RemoveFromFolderCommand.ExecuteAsync(row);