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);
}
/// One past the highest sort_order in use — across both tables, because
/// the sidebar interleaves folders and top-level lists in a single order sequence.
private async Task 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 GetByIdAsync(string listId, CancellationToken ct = default)
{
return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
}
public async Task> GetAllAsync(CancellationToken ct = default)
{
return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct);
}
///
/// Writes the sidebar's visual order as one sequence spanning both tables: folders and lists
/// share the sort_order 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
/// sort_order and lists by sort_order independently still reproduces the sidebar.
///
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> 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);
}
/// Patches just the two editable fields — a whole-entity Update would need
/// CreatedAt/SortOrder/IsCollapsed carried along and silently reset
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).
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);
}
/// Deletes the folder only — its lists survive and fall back to the top level via
/// the ON DELETE SET NULL on lists.folder_id.
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);
}
/// 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)
{
await _context.Lists
.Where(l => l.Id == listId)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FolderId, folderId), ct);
}
public async Task 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 DeleteConfigAsync(string listId, CancellationToken ct = default)
{
var affected = await _context.ListConfigs
.Where(c => c.ListId == listId)
.ExecuteDeleteAsync(ct);
return affected > 0;
}
}