feat(data): list_folders table plus lists.folder_id
Sidebar folders get their own table rather than a flag on lists, so the places that enumerate lists (MCP tools, move-to-list, queue picker, repo import) need no folder filtering. ON DELETE SET NULL keeps a folder's lists alive when the folder goes.
This commit is contained in:
@@ -43,6 +43,7 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||||
public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
|
public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
|
||||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||||
|
public DbSet<ListFolderEntity> ListFolders => Set<ListFolderEntity>();
|
||||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||||
|
|||||||
@@ -20,8 +20,17 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
|||||||
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
|
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
|
||||||
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
|
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
|
||||||
builder.Property(l => l.Icon).HasColumnName("icon");
|
builder.Property(l => l.Icon).HasColumnName("icon");
|
||||||
|
builder.Property(l => l.FolderId).HasColumnName("folder_id");
|
||||||
|
|
||||||
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
||||||
|
builder.HasIndex(l => l.FolderId).HasDatabaseName("idx_lists_folder");
|
||||||
|
|
||||||
|
// No navigation property on either side: nothing needs the graph, and a nav would have to
|
||||||
|
// be Include'd (or silently come back empty) in every list query.
|
||||||
|
builder.HasOne<ListFolderEntity>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(l => l.FolderId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
builder.HasOne(l => l.Config)
|
builder.HasOne(l => l.Config)
|
||||||
.WithOne(c => c.List)
|
.WithOne(c => c.List)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Configuration;
|
||||||
|
|
||||||
|
public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolderEntity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ListFolderEntity> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("list_folders");
|
||||||
|
|
||||||
|
builder.HasKey(f => f.Id);
|
||||||
|
builder.Property(f => f.Id).HasColumnName("id");
|
||||||
|
builder.Property(f => f.Name).HasColumnName("name").IsRequired();
|
||||||
|
builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||||
|
builder.Property(f => f.Icon).HasColumnName("icon");
|
||||||
|
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
|
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
|
||||||
|
|
||||||
|
builder.HasIndex(f => f.SortOrder).HasDatabaseName("idx_list_folders_sort");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddListFolders : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "folder_id",
|
||||||
|
table: "lists",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "list_folders",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
icon = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
sort_order = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
|
||||||
|
is_collapsed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_list_folders", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_lists_folder",
|
||||||
|
table: "lists",
|
||||||
|
column: "folder_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_list_folders_sort",
|
||||||
|
table: "list_folders",
|
||||||
|
column: "sort_order");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_lists_list_folders_folder_id",
|
||||||
|
table: "lists",
|
||||||
|
column: "folder_id",
|
||||||
|
principalTable: "list_folders",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_lists_list_folders_folder_id",
|
||||||
|
table: "lists");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "list_folders");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "idx_lists_folder",
|
||||||
|
table: "lists");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "folder_id",
|
||||||
|
table: "lists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,6 +324,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasDefaultValue(false)
|
.HasDefaultValue(false)
|
||||||
.HasColumnName("findings_tracked");
|
.HasColumnName("findings_tracked");
|
||||||
|
|
||||||
|
b.Property<string>("FolderId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("folder_id");
|
||||||
|
|
||||||
b.Property<string>("Icon")
|
b.Property<string>("Icon")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("icon");
|
.HasColumnName("icon");
|
||||||
@@ -351,12 +355,54 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FolderId")
|
||||||
|
.HasDatabaseName("idx_lists_folder");
|
||||||
|
|
||||||
b.HasIndex("SortOrder")
|
b.HasIndex("SortOrder")
|
||||||
.HasDatabaseName("idx_lists_sort");
|
.HasDatabaseName("idx_lists_sort");
|
||||||
|
|
||||||
b.ToTable("lists", (string)null);
|
b.ToTable("lists", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("icon");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCollapsed")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_collapsed");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SortOrder")
|
||||||
|
.HasDatabaseName("idx_list_folders_sort");
|
||||||
|
|
||||||
|
b.ToTable("list_folders", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -934,6 +980,14 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.Navigation("List");
|
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.SubtaskEntity", b =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public sealed class ListEntity
|
|||||||
public bool FindingsTracked { get; set; }
|
public bool FindingsTracked { get; set; }
|
||||||
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
|
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
|
||||||
public string? Icon { get; set; }
|
public string? Icon { get; set; }
|
||||||
|
// Sidebar folder this list sits in (ListFolderEntity.Id); null = top level. ON DELETE SET NULL,
|
||||||
|
// so deleting a folder moves its lists back to the top level instead of deleting them.
|
||||||
|
public string? FolderId { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListConfigEntity? Config { get; set; }
|
public ListConfigEntity? Config { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on
|
||||||
|
/// <see cref="ListEntity"/>: every place that enumerates lists (MCP list_task_lists, "move to
|
||||||
|
/// list", the queue picker, repo import) would otherwise have to filter folders out.
|
||||||
|
/// One level only — a folder holds lists, never other folders.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ListFolderEntity
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required DateTime CreatedAt { get; init; }
|
||||||
|
// Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon.
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
// 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; }
|
||||||
|
}
|
||||||
@@ -68,6 +68,58 @@ public sealed class ListRepository
|
|||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Sidebar folders (list_folders) ----
|
||||||
|
// Folders live here rather than in their own repository because they exist only as a grouping
|
||||||
|
// of these lists — every caller that touches one already holds a ListRepository.
|
||||||
|
|
||||||
|
public async Task<List<ListFolderEntity>> GetFoldersAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.ListFolders
|
||||||
|
.OrderBy(f => f.SortOrder).ThenBy(f => f.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddFolderAsync(ListFolderEntity folder, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
folder.SortOrder = await _context.ListFolders.CountAsync(ct);
|
||||||
|
_context.ListFolders.Add(folder);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Patches just the two editable fields — a whole-entity Update would need
|
||||||
|
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
|
||||||
|
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
|
||||||
|
public async Task RenameFolderAsync(string folderId, string name, string? icon, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.ListFolders
|
||||||
|
.Where(f => f.Id == folderId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(f => f.Name, name)
|
||||||
|
.SetProperty(f => f.Icon, icon), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
|
||||||
|
/// the ON DELETE SET NULL on <c>lists.folder_id</c>.</summary>
|
||||||
|
public async Task DeleteFolderAsync(string folderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.ListFolders.Where(f => f.Id == folderId).ExecuteDeleteAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetFolderCollapsedAsync(string folderId, bool collapsed, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.ListFolders
|
||||||
|
.Where(f => f.Id == folderId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
|
||||||
|
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.Lists
|
||||||
|
.Where(l => l.Id == listId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FolderId, folderId), ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
||||||
|
|||||||
Reference in New Issue
Block a user