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