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]