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);