diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 283d2756..2fb99e15 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -67,7 +67,8 @@ All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued → Running` cl Worker's `QueuePicker` (`FromSqlRaw`), **not** here. - **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two. -- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`, plus the `list_folders` set: `GetFoldersAsync`, `AddFolderAsync` (appends, `SortOrder = COUNT(*)`), `RenameFolderAsync`, `DeleteFolderAsync` (folder only — the FK releases its lists), `SetFolderCollapsedAsync`, `SetListFolderAsync`. The folder writers are `ExecuteUpdate` patches, not entity `Update()`s, so a rename can't reset `SortOrder`/`IsCollapsed` the way `SetConfigAsync`'s verbatim copy resets forgotten columns. +- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`, plus the `list_folders` set: `GetFoldersAsync`, `AddFolderAsync` (appends), `RenameFolderAsync`, `DeleteFolderAsync` (folder only — the FK releases its lists), `SetFolderCollapsedAsync`, `SetListFolderAsync`. The folder writers are `ExecuteUpdate` patches, not entity `Update()`s, so a rename can't reset `SortOrder`/`IsCollapsed` the way `SetConfigAsync`'s verbatim copy resets forgotten columns. + - ⚠️ **`lists.sort_order` and `list_folders.sort_order` share one number space** — the sidebar interleaves folders and top-level lists in a single order, so `ReorderSidebarAsync` numbers the whole visual sequence (each folder followed by its children) across both tables, and `AddAsync`/`AddFolderAsync` append via `MAX(sort_order)+1` over **both**. Never renumber one table on its own. - **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit) - **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`. Bulk delete-per-task is the `ON DELETE CASCADE` on `task_attachments`, not a repository method. - **DailyNoteRepository**, **WeekReportRepository**, **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository** diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index acb5325f..33d416cf 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -17,10 +17,20 @@ public sealed class ListRepository 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); @@ -56,14 +66,27 @@ public sealed class ListRepository return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct); } - public async Task ReorderAsync(IReadOnlyList orderedListIds, CancellationToken ct = default) + /// + /// 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 idSet = orderedListIds.ToHashSet(); - var entities = await _context.Lists.Where(l => idSet.Contains(l.Id)).ToListAsync(ct); - for (int i = 0; i < orderedListIds.Count; i++) + 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 e = entities.FirstOrDefault(x => x.Id == orderedListIds[i]); - if (e is not null) e.SortOrder = 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); } @@ -81,7 +104,7 @@ public sealed class ListRepository public async Task AddFolderAsync(ListFolderEntity folder, CancellationToken ct = default) { - folder.SortOrder = await _context.ListFolders.CountAsync(ct); + folder.SortOrder = await NextSortOrderAsync(ct); _context.ListFolders.Add(folder); await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 9b56d6e4..c98961f5 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:** `Folders` (`ListFolderNavItemViewModel`, each with its own `Lists`) + `RootLists` are what the sidebar binds to; `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. 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. `ReorderAsync` doubles as the move: a drop position implies a container, so dropping onto a row in another folder (or a top-level row) reassigns `FolderId` — that is the only way to drag a list *out*. `MoveToFolderAsync` is the drop-onto-the-header path (reaches collapsed/empty folders); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync`, which writes the whole sidebar back as one global `sort_order` sequence (folder contents in folder order, then top level). Folders never nest. + - **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. - **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 38015f9f..be123cbf 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListFolderNavItemViewModel.cs @@ -16,8 +16,12 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase [ObservableProperty] private bool _isCollapsed; // Sum of the child lists' counts — a collapsed folder still shows what's inside it. [ObservableProperty] private int _count; - // Set while a dragged list row hovers over this folder header (move-into-folder target). + // Set while a dragged list row hovers over the middle of this folder header (move-into-folder). [ObservableProperty] private bool _isDropTarget; + // Insert-position hints for a drop next to the folder instead of into it. Drawn around the + // whole folder block (header + children), not just the header. + [ObservableProperty] private bool _dropHintAbove; + [ObservableProperty] private bool _dropHintBelow; public ObservableCollection Lists { get; } = new(); } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index 7953b72e..479b7604 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -209,12 +209,19 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable /// Every user list, flat, regardless of folder — the set the count refresh, the /// row refresh and the no-repo banner work over. public ObservableCollection UserLists { get; } = new(); - /// Sidebar folders, rendered above the top-level lists. - public ObservableCollection Folders { get; } = new(); - /// The user lists that sit outside any folder. Together with - /// this is what the sidebar binds to; stays - /// the flat superset. - public ObservableCollection RootLists { get; } = new(); + /// The sidebar's top level, in render order: folder rows + /// () and folder-less lists + /// () interleaved, so a list can sit above a folder. + /// 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(); + /// The user lists that sit outside any folder — likewise a projection. + public IReadOnlyList RootLists => + TopLevel.OfType().ToList(); [ObservableProperty] private string _searchText = ""; [ObservableProperty] private ListNavItemViewModel? _selectedList; @@ -291,8 +298,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable Items.Clear(); SmartLists.Clear(); UserLists.Clear(); - Folders.Clear(); - RootLists.Clear(); + TopLevel.Clear(); var smart = new[] { @@ -314,14 +320,22 @@ 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)>(); + var foldersById = new Dictionary(); foreach (var f in await lists.GetFoldersAsync(ct)) - Folders.Add(new ListFolderNavItemViewModel + { + var folder = new ListFolderNavItemViewModel { Id = f.Id, Name = f.Name, IconKey = f.Icon ?? ListFolderIcon, IsCollapsed = f.IsCollapsed, - }); + }; + foldersById[f.Id] = folder; + topLevel.Add((f.SortOrder, folder)); + } var seedNames = new HashSet(new[] { "My Day", "Important", "Planned" }); foreach (var l in await lists.GetAllAsync(ct)) @@ -343,8 +357,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable }; Items.Add(item); UserLists.Add(item); - ContainerOf(item).Add(item); + if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner)) + owner.Lists.Add(item); + else + topLevel.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); ok = true; } finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); } @@ -431,7 +453,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable }; Items.Add(item); UserLists.Add(item); - RootLists.Add(item); + TopLevel.Add(item); SelectedList = item; RecomputeHasNoLinkedRepo(); @@ -462,55 +484,83 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable r.DropHintAbove = false; r.DropHintBelow = false; } - foreach (var f in Folders) f.IsDropTarget = false; + foreach (var f in Folders) + { + f.IsDropTarget = false; + f.DropHintAbove = false; + f.DropHintBelow = false; + } } - public void SetDropHint(ListNavItemViewModel target, bool placeBelow) + /// Marks the insert position for a drop next to — either a + /// list row or a folder header (folders take the same two hint lines, drawn around the whole + /// folder block). + public void SetDropHint(object target, bool placeBelow) { - foreach (var r in UserLists) + ClearDropHints(); + switch (target) { - var isTarget = ReferenceEquals(r, target); - r.DropHintAbove = isTarget && !placeBelow; - r.DropHintBelow = isTarget && placeBelow; + case ListNavItemViewModel row: + row.DropHintAbove = !placeBelow; + row.DropHintBelow = placeBelow; + break; + case ListFolderNavItemViewModel folder: + folder.DropHintAbove = !placeBelow; + folder.DropHintBelow = placeBelow; + break; } - foreach (var f in Folders) f.IsDropTarget = false; } public void SetFolderDropTarget(ListFolderNavItemViewModel? target) { - foreach (var r in UserLists) - { - r.DropHintAbove = false; - r.DropHintBelow = false; - } - foreach (var f in Folders) f.IsDropTarget = ReferenceEquals(f, target); + ClearDropHints(); + if (target is not null) target.IsDropTarget = true; } /// The collection a row is rendered in: its folder's Lists, or - /// when it has no folder. - public ObservableCollection ContainerOf(ListNavItemViewModel row) => - Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? RootLists; + /// 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; + + /// Kept for the list→list case; covers all four combinations. + public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) => + DropAsync(source, target, placeBelow); /// - /// Drops at 's position. Since a drop - /// position implies a container, a drag onto a row in a different folder (or onto a top-level - /// row) also *moves* the list there — that's the only way to drag a list out of a folder. + /// 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 + /// 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. /// - public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) + public async Task DropAsync(object source, object target, bool placeBelow) { - if (source.Kind != ListKind.User || target.Kind != ListKind.User) return; if (ReferenceEquals(source, target)) return; + if (target is ListNavItemViewModel { Kind: not ListKind.User }) return; - var folderChanged = source.FolderId != target.FolderId; - if (folderChanged) + if (source is ListFolderNavItemViewModel folder) { - ContainerOf(source).Remove(source); - source.FolderId = target.FolderId; - ContainerOf(source).Add(source); + if (target is ListNavItemViewModel { FolderId: not null }) return; + MoveWithinCollection(TopLevel, folder, target, placeBelow); + await PersistOrderAsync(null); + return; } - MoveWithinCollection(ContainerOf(source), source, target, placeBelow); - await PersistOrderAsync(folderChanged ? source : null); + 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; + var folderChanged = row.FolderId != targetFolderId; + if (folderChanged) + { + ContainerOf(row).Remove(row); + row.FolderId = targetFolderId; + ContainerOf(row).Add(row); + } + + MoveWithinCollection(ContainerOf(row), row, target, placeBelow); + await PersistOrderAsync(folderChanged ? row : null); } /// Appends a list to a folder (or to the top level when is @@ -533,10 +583,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable /// private async Task PersistOrderAsync(ListNavItemViewModel? movedRow) { - var orderedIds = Folders.SelectMany(f => f.Lists) - .Concat(RootLists) - .Select(i => RawId(i.Id)) - .ToList(); + var ordered = new List<(string Id, bool IsFolder)>(); + foreach (var item in TopLevel) + { + switch (item) + { + 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; + } + } var sw = System.Diagnostics.Stopwatch.StartNew(); var ok = false; @@ -546,7 +606,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable var lists = new ListRepository(ctx); if (movedRow is not null) await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId); - await lists.ReorderAsync(orderedIds); + await lists.ReorderSidebarAsync(ordered); ok = true; } finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); } @@ -601,9 +661,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable rowId.StartsWith("user:", StringComparison.Ordinal) ? rowId["user:".Length..] : rowId; private static void MoveWithinCollection( - ObservableCollection coll, - ListNavItemViewModel source, - ListNavItemViewModel target, + System.Collections.IList coll, + object source, + object target, bool placeBelow) { var srcIdx = coll.IndexOf(source); @@ -616,7 +676,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable if (finalIdx >= coll.Count) finalIdx = coll.Count - 1; if (finalIdx == srcIdx) return; - coll.Move(srcIdx, finalIdx); + coll.RemoveAt(srcIdx); + coll.Insert(finalIdx, source); } partial void OnSelectedListChanged(ListNavItemViewModel? value) @@ -717,14 +778,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable UserLists.Insert(insertAt, item); Items.Insert(SmartLists.Count + insertAt, item); - // Same DB-order position, but counted within the row's own container. - var container = ContainerOf(item); - var containerIdx = ordered - .Where(l => l.FolderId == item.FolderId) - .Select(l => l.Id) - .ToList() - .IndexOf(entity.Id); - container.Insert(containerIdx < 0 || containerIdx > container.Count ? container.Count : containerIdx, item); + // Inside a folder, the same DB-order position; at the top level just appended — the mixed + // top level has no index that a per-folder DB order maps onto, and AddAsync numbers a new + // list past everything anyway. + if (Folders.FirstOrDefault(f => f.Id == item.FolderId) is { } folder) + { + var containerIdx = ordered + .Where(l => l.FolderId == item.FolderId) + .Select(l => l.Id) + .ToList() + .IndexOf(entity.Id); + folder.Lists.Insert( + containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx, + item); + } + else TopLevel.Add(item); RecomputeHasNoLinkedRepo(); } } diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index f4475f07..1670c44c 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -5,10 +5,13 @@ xmlns:loc="using:ClaudeDo.Ui.Localization" x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView" x:DataType="vm:ListsIslandViewModel"> - - + + - + @@ -88,7 +91,65 @@ IsVisible="{Binding DropHintBelow}"/> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -218,60 +279,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs index cdaef3d7..c1002d47 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs @@ -17,11 +17,28 @@ public partial class ListsIslandView : UserControl { private static readonly DataFormat ListRowFormat = DataFormat.CreateStringApplicationFormat("claudedo-list-row"); + private static readonly DataFormat FolderRowFormat = + DataFormat.CreateStringApplicationFormat("claudedo-folder-row"); + + // A folder drag only starts once the pointer has moved: DoDragDropAsync captures the pointer, + // and starting it on press would swallow the Tapped that folds the folder. It only takes the + // *pressed* args, so the press is kept around until the threshold is crossed. + private ListFolderNavItemViewModel? _pendingFolderDrag; + private PointerPressedEventArgs? _pendingFolderDragPress; + private Point _folderDragOrigin; + + private void CancelPendingFolderDrag() + { + _pendingFolderDrag = null; + _pendingFolderDragPress = null; + } public ListsIslandView() { InitializeComponent(); AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel); + AddHandler(PointerMovedEvent, OnTunnelPointerMoved, RoutingStrategies.Tunnel); + AddHandler(PointerReleasedEvent, (_, _) => CancelPendingFolderDrag(), RoutingStrategies.Tunnel); DataContextChanged += (_, _) => { if (DataContext is ListsIslandViewModel vm) @@ -42,9 +59,19 @@ public partial class ListsIslandView : UserControl if (e.Source is not Visual src) return; var border = FindListItemBorder(src); - if (border?.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return; + if (border is null) return; if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) return; + if (border.DataContext is ListFolderNavItemViewModel folder) + { + _pendingFolderDrag = folder; + _pendingFolderDragPress = e; + _folderDragOrigin = e.GetPosition(this); + return; + } + + if (border.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return; + // Double-click opens the list's settings instead of starting a drag. Handled here // because DoDragDropAsync captures the pointer and would swallow a DoubleTapped event. if (e.ClickCount == 2) @@ -69,6 +96,29 @@ public partial class ListsIslandView : UserControl } } + private async void OnTunnelPointerMoved(object? sender, PointerEventArgs e) + { + if (_pendingFolderDrag is not { } folder || _pendingFolderDragPress is not { } press) return; + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { CancelPendingFolderDrag(); return; } + + var delta = e.GetPosition(this) - _folderDragOrigin; + if (Math.Abs(delta.X) < 4 && Math.Abs(delta.Y) < 4) return; + + CancelPendingFolderDrag(); + if (DataContext is not ListsIslandViewModel vm) return; + + var data = new DataTransfer(); + data.Add(DataTransferItem.Create(FolderRowFormat, folder.Id)); + try + { + await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Move); + } + finally + { + vm.ClearDropHints(); + } + } + private void OnFolderTapped(object? sender, RoutedEventArgs e) { if (sender is Border { DataContext: ListFolderNavItemViewModel folder } @@ -76,30 +126,68 @@ public partial class ListsIslandView : UserControl vm.ToggleFolderCommand.Execute(folder); } - // Dropping onto the folder *header* appends the list to that folder — the only route into a - // collapsed or empty folder, where there's no child row to drop next to. + /// The dragged row or folder, resolved from whichever of the two formats is present. + private static object? DragSource(ListsIslandViewModel vm, DragEventArgs e) + { + if (e.DataTransfer is not { } data) return null; + if (data.Contains(ListRowFormat)) + return data.TryGetValue(ListRowFormat) is { } listId + ? vm.UserLists.FirstOrDefault(r => r.Id == listId) : null; + if (data.Contains(FolderRowFormat)) + return data.TryGetValue(FolderRowFormat) is { } folderId + ? vm.Folders.FirstOrDefault(f => f.Id == folderId) : null; + return null; + } + + 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 + /// (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. + /// + 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 (source is not ListNavItemViewModel row) return (FolderDrop.None, false); + if (y < height * 0.25) return (FolderDrop.Beside, false); + if (y > height * 0.75) return (FolderDrop.Beside, true); + return row.FolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false); + } + private void OnFolderDragOver(object? sender, DragEventArgs e) { if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } - if ((!e.DataTransfer?.Contains(ListRowFormat) ?? true) - || sender is not Border { DataContext: ListFolderNavItemViewModel folder }) + if (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder) { - e.DragEffects = DragDropEffects.None; - vm.ClearDropHints(); + Reject(vm, e); return; } - var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat); - var source = sourceId is null ? null : vm.UserLists.FirstOrDefault(r => r.Id == sourceId); - if (source is null || source.FolderId == folder.Id) - { - e.DragEffects = DragDropEffects.None; - vm.ClearDropHints(); - return; - } + var source = DragSource(vm, e); + if (source is null) { Reject(vm, e); return; } - vm.SetFolderDropTarget(folder); - e.DragEffects = DragDropEffects.Move; + var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height); + switch (kind) + { + case FolderDrop.Into: + vm.SetFolderDropTarget(folder); + e.DragEffects = DragDropEffects.Move; + break; + case FolderDrop.Beside: + vm.SetDropHint(folder, placeBelow); + e.DragEffects = DragDropEffects.Move; + break; + default: + Reject(vm, e); + break; + } } private async void OnFolderDrop(object? sender, DragEventArgs e) @@ -107,14 +195,16 @@ public partial class ListsIslandView : UserControl if (DataContext is not ListsIslandViewModel vm) return; try { - if (sender is not Border { DataContext: ListFolderNavItemViewModel folder }) return; - - var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat); - var source = sourceId is null ? null : vm.UserLists.FirstOrDefault(r => r.Id == sourceId); + if (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder) return; + var source = DragSource(vm, e); if (source is null) return; + var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height); vm.ClearDropHints(); - await vm.MoveToFolderAsync(source, folder); + if (kind == FolderDrop.Into && source is ListNavItemViewModel row) + await vm.MoveToFolderAsync(row, folder); + else if (kind == FolderDrop.Beside) + await vm.DropAsync(source, folder, placeBelow); } catch { @@ -126,47 +216,29 @@ public partial class ListsIslandView : UserControl private void OnListDragOver(object? sender, DragEventArgs e) { if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } - if (!e.DataTransfer?.Contains(ListRowFormat) ?? true) - { - e.DragEffects = DragDropEffects.None; - vm.ClearDropHints(); - return; - } if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) { - e.DragEffects = DragDropEffects.None; - vm.ClearDropHints(); + Reject(vm, e); return; } - var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat); - if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) - { - e.DragEffects = DragDropEffects.None; - vm.ClearDropHints(); - return; - } - - var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2; + 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. - ListNavItemViewModel hintRow = target; - bool hintBelow = false; - if (placeBelow) + object hintTarget = target; + var hintBelow = false; + if (e.GetPosition(b).Y > b.Bounds.Height / 2) { - var next = FindNextUserList(vm, target); - if (next is not null) { hintRow = next; hintBelow = false; } - else { hintRow = target; hintBelow = true; } + if (FindNextInContainer(vm, target) is { } next) hintTarget = next; + else hintBelow = true; } - if (hintRow.Id == sourceId) - { - e.DragEffects = DragDropEffects.None; - vm.ClearDropHints(); - return; - } + if (ReferenceEquals(hintTarget, source)) { Reject(vm, e); return; } - vm.SetDropHint(hintRow, hintBelow); + vm.SetDropHint(hintTarget, hintBelow); e.DragEffects = DragDropEffects.Move; } @@ -177,15 +249,13 @@ public partial class ListsIslandView : UserControl { if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) return; - var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat); - if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return; - - var source = vm.UserLists.FirstOrDefault(r => r.Id == sourceId); - if (source is null) return; + 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(); - await vm.ReorderAsync(source, target, placeBelow); + await vm.DropAsync(source, target, placeBelow); } catch { @@ -194,6 +264,12 @@ public partial class ListsIslandView : UserControl } } + private static void Reject(ListsIslandViewModel vm, DragEventArgs e) + { + e.DragEffects = DragDropEffects.None; + vm.ClearDropHints(); + } + private static Border? FindListItemBorder(Visual? v) { while (v is not null) @@ -204,9 +280,10 @@ public partial class ListsIslandView : UserControl return null; } - // Next row *within the same container* — the row below the last list of a folder belongs to - // another folder (or to the top level), so it is not a "drop above" candidate here. - private static ListNavItemViewModel? FindNextUserList(ListsIslandViewModel vm, ListNavItemViewModel row) + // Next item *within the same container* — the row below the last list of a folder belongs to + // another container, so it is not a "drop above" candidate here. At the top level the next + // item can be a folder, which is a valid hint target. + private static object? FindNextInContainer(ListsIslandViewModel vm, ListNavItemViewModel row) { var container = vm.ContainerOf(row); var idx = container.IndexOf(row); diff --git a/tests/ClaudeDo.Data.Tests/ListFolderTests.cs b/tests/ClaudeDo.Data.Tests/ListFolderTests.cs index a6f6cca6..39a350ac 100644 --- a/tests/ClaudeDo.Data.Tests/ListFolderTests.cs +++ b/tests/ClaudeDo.Data.Tests/ListFolderTests.cs @@ -102,6 +102,36 @@ public sealed class ListFolderTests : IDisposable Assert.Null((await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId); } + [Fact] + public async Task ReorderSidebarAsync_numbers_folders_and_lists_in_one_sequence() + { + await using var ctx = Open(); + var repo = new ListRepository(ctx); + await repo.AddFolderAsync(NewFolder("f1")); + await repo.AddAsync(new ListEntity { Id = "inside", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" }); + await repo.AddAsync(new ListEntity { Id = "top", Name = "Top", CreatedAt = DateTime.UtcNow }); + + // Sidebar: the top-level list above the folder, whose one child follows the folder. + await repo.ReorderSidebarAsync([("top", false), ("f1", true), ("inside", false)]); + + Assert.Equal(0, (await repo.GetByIdAsync("top"))!.SortOrder); + Assert.Equal(1, (await repo.GetFoldersAsync()).Single().SortOrder); + Assert.Equal(2, (await repo.GetByIdAsync("inside"))!.SortOrder); + } + + [Fact] + public async Task AddAsync_appends_past_the_folders_too() + { + await using var ctx = Open(); + var repo = new ListRepository(ctx); + await repo.AddFolderAsync(NewFolder("f1")); + await repo.AddFolderAsync(NewFolder("f2")); + await repo.AddAsync(new ListEntity { Id = "l1", Name = "Fresh", CreatedAt = DateTime.UtcNow }); + + // 2, not 0 — a new list must not jump above the existing folders on the next load. + Assert.Equal(2, (await repo.GetByIdAsync("l1"))!.SortOrder); + } + [Fact] public async Task AddFolderAsync_appends_in_creation_order() { diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs index b21a2127..b243c371 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/ListsIslandFolderTests.cs @@ -48,11 +48,14 @@ public class ListsIslandFolderTests : IDisposable public override bool IsConnected => true; } - private async Task SeedAsync(string? folderId = null, params (string Id, string Name, int Sort)[] lists) + private async Task SeedAsync(string? folderId = null, params (string Id, string Name, int Sort)[] lists) => + await SeedAsync(folderId, 0, lists); + + private async Task SeedAsync(string? folderId, int folderSort, params (string Id, string Name, int Sort)[] lists) { await using var db = NewContext(); if (folderId is not null) - db.ListFolders.Add(new ListFolderEntity { Id = folderId, Name = $"Folder {folderId}", CreatedAt = DateTime.UtcNow }); + db.ListFolders.Add(new ListFolderEntity { Id = folderId, Name = $"Folder {folderId}", SortOrder = folderSort, CreatedAt = DateTime.UtcNow }); foreach (var (id, name, sort) in lists) db.Lists.Add(new ListEntity { Id = id, Name = name, SortOrder = sort, CreatedAt = DateTime.UtcNow, FolderId = folderId }); await db.SaveChangesAsync(); @@ -164,9 +167,74 @@ public class ListsIslandFolderTests : IDisposable await using var db = NewContext(); var stored = await db.Lists.AsNoTracking().ToListAsync(); Assert.Equal("f1", stored.Single(l => l.Id == "mover").FolderId); - // sort_order is written as one global sequence in sidebar order: folder contents first. - Assert.Equal(0, stored.Single(l => l.Id == "mover").SortOrder); - Assert.Equal(1, stored.Single(l => l.Id == "inside").SortOrder); + // sort_order is one sequence over the whole sidebar, folders included: f1 takes 0, its + // two children 1 and 2. + Assert.Equal(0, (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).SortOrder); + Assert.Equal(1, stored.Single(l => l.Id == "mover").SortOrder); + Assert.Equal(2, stored.Single(l => l.Id == "inside").SortOrder); + } + + [Fact] + public async Task DropAsync_reorders_folders_among_each_other_and_persists_it() + { + await SeedAsync("f1", folderSort: 0); + await SeedAsync("f2", folderSort: 1); + + var vm = NewVm(); + await vm.LoadAsync(); + var (first, second) = (vm.Folders[0], vm.Folders[1]); + + await vm.DropAsync(second, first, placeBelow: false); + + Assert.Equal([second, first], vm.Folders); + await using var db = NewContext(); + var stored = await db.ListFolders.AsNoTracking().ToListAsync(); + Assert.Equal(0, stored.Single(f => f.Id == second.Id).SortOrder); + Assert.Equal(1, stored.Single(f => f.Id == first.Id).SortOrder); + + // And it survives a reload — the load path merges folders and lists by sort_order. + var reloaded = NewVm(); + await reloaded.LoadAsync(); + Assert.Equal([second.Id, first.Id], reloaded.Folders.Select(f => f.Id)); + } + + [Fact] + public async Task DropAsync_puts_a_list_above_a_folder_at_the_top_level() + { + await SeedAsync("f1", ("inside", "Inside", 1)); + await SeedAsync(null, ("mover", "Mover", 2)); + + var vm = NewVm(); + await vm.LoadAsync(); + var folder = vm.Folders[0]; + var mover = vm.RootLists.Single(); + + await vm.DropAsync(mover, folder, placeBelow: false); + + Assert.Equal(new object[] { mover, folder }, vm.TopLevel); + Assert.Null(mover.FolderId); + + var reloaded = NewVm(); + await reloaded.LoadAsync(); + Assert.Equal(["user:mover", "f1"], reloaded.TopLevel.Select(i => + i is ListNavItemViewModel l ? l.Id : ((ListFolderNavItemViewModel)i).Id)); + } + + [Fact] + public async Task DropAsync_never_nests_a_folder_in_a_folder() + { + 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]; + + await vm.DropAsync(f2, inside, placeBelow: true); + + Assert.Equal(2, vm.TopLevel.Count); + Assert.Single(vm.Folders[0].Lists); } [Fact]