Files
ClaudeDo/src/ClaudeDo.Data/Repositories/ListRepository.cs
T
Mika KunsandClaude Sonnet 5 0dac047c46 feat(lists): allow nested sidebar folders via drag and drop
Folders can now contain other folders, to arbitrary depth, via a
self-referencing parent_folder_id (ON DELETE SET NULL, same pattern as
lists.folder_id). Nesting/un-nesting is drag-and-drop only: dropping a
folder onto another folder's header middle moves it in, the outer
quarters or any top-level/in-folder drop insert it beside a target.
Dropping a folder into itself or one of its own subfolders is silently
ignored. Deleting a folder releases its lists and subfolders back to
the top level, unchanged from the existing list behavior.

ListFolderNavItemViewModel.Lists became Children (object, mixing lists
and subfolders, mirroring TopLevel) so the existing implicit, type-matched
DataTemplates render nested folders recursively without new XAML, and each
nesting level gets its indent for free from the folder template's own
margin. Folders/RootLists/ContainerOf/PersistOrderAsync/RecomputeFolderCounts
now walk the tree instead of assuming one level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:31:26 +02:00

189 lines
8.1 KiB
C#

using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository
{
private readonly ClaudeDoDbContext _context;
private readonly AttachmentStore _attachments;
public ListRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null)
{
_context = context;
_attachments = attachments ?? new AttachmentStore();
}
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
if (entity.SortOrder == 0) entity.SortOrder = await NextSortOrderAsync(ct);
_context.Lists.Add(entity);
await _context.SaveChangesAsync(ct);
}
/// <summary>One past the highest <c>sort_order</c> in use — across <b>both</b> tables, because
/// the sidebar interleaves folders and top-level lists in a single order sequence.</summary>
private async Task<int> NextSortOrderAsync(CancellationToken ct = default)
{
var maxList = await _context.Lists.MaxAsync(l => (int?)l.SortOrder, ct) ?? -1;
var maxFolder = await _context.ListFolders.MaxAsync(f => (int?)f.SortOrder, ct) ?? -1;
return Math.Max(maxList, maxFolder) + 1;
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
_context.Lists.Update(entity);
await _context.SaveChangesAsync(ct);
}
// A working dir is user-supplied (typed, folder-picked, imported, or set over MCP) and every
// consumer eventually hands it to the claude CLI or wt.exe as one quoted command-line token —
// where a trailing '\' escapes its own closing quote. Normalizing here covers all writers;
// see Paths.TrimTrailingSeparator.
private static void NormalizeWorkingDir(ListEntity entity) =>
entity.WorkingDir = Paths.TrimTrailingSeparator(entity.WorkingDir);
public async Task DeleteAsync(string listId, CancellationToken ct = default)
{
var taskIds = await _context.Tasks
.Where(t => t.ListId == listId)
.Select(t => t.Id)
.ToListAsync(ct);
await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
foreach (var id in taskIds)
_attachments.DeleteTaskDir(id);
}
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
{
return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
}
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{
return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct);
}
/// <summary>
/// Writes the sidebar's visual order as one sequence spanning both tables: folders and lists
/// share the <c>sort_order</c> number space, which is what lets a top-level list sit between
/// two folders. A folder's own children follow it in the sequence, so ordering folders by
/// <c>sort_order</c> and lists by <c>sort_order</c> independently still reproduces the sidebar.
/// </summary>
public async Task ReorderSidebarAsync(IReadOnlyList<(string Id, bool IsFolder)> ordered, CancellationToken ct = default)
{
var listIds = ordered.Where(o => !o.IsFolder).Select(o => o.Id).ToHashSet();
var folderIds = ordered.Where(o => o.IsFolder).Select(o => o.Id).ToHashSet();
var lists = await _context.Lists.Where(l => listIds.Contains(l.Id)).ToListAsync(ct);
var folders = await _context.ListFolders.Where(f => folderIds.Contains(f.Id)).ToListAsync(ct);
for (int i = 0; i < ordered.Count; i++)
{
var (id, isFolder) = ordered[i];
if (isFolder)
{
if (folders.FirstOrDefault(f => f.Id == id) is { } folder) folder.SortOrder = i;
}
else if (lists.FirstOrDefault(l => l.Id == id) is { } list) list.SortOrder = i;
}
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 NextSortOrderAsync(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 folder under <paramref name="parentFolderId"/>, or to the top level when null.</summary>
public async Task SetFolderParentAsync(string folderId, string? parentFolderId, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.ParentFolderId, parentFolderId), ct);
}
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
{
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)
{
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
}
public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default)
{
var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct);
if (existing is null)
{
_context.ListConfigs.Add(config);
}
else
{
existing.Model = config.Model;
existing.SystemPrompt = config.SystemPrompt;
existing.AgentPath = config.AgentPath;
existing.MaxTurns = config.MaxTurns;
existing.SessionSkills = config.SessionSkills;
existing.VerifyCommand = config.VerifyCommand;
existing.PermissionMode = config.PermissionMode;
existing.TicketProjectId = config.TicketProjectId;
existing.PowerUseAllowed = config.PowerUseAllowed;
}
await _context.SaveChangesAsync(ct);
}
public async Task<bool> DeleteConfigAsync(string listId, CancellationToken ct = default)
{
var affected = await _context.ListConfigs
.Where(c => c.ListId == listId)
.ExecuteDeleteAsync(ct);
return affected > 0;
}
}