feat(ui): Listen und Ordner in einer gemischten Sidebar-Reihenfolge

Die oberste Sidebar-Ebene ist jetzt eine gemischte TopLevel-Collection, so
dass eine Liste auch oberhalb eines Ordners stehen kann. DropAsync deckt
alle vier Quelle/Ziel-Kombinationen ab, ReorderSidebarAsync numeriert die
gesamte sichtbare Reihenfolge ueber lists und list_folders hinweg.
This commit is contained in:
mika kuns
2026-09-10 15:40:10 +02:00
parent 1770b67713
commit bfb1c6523c
9 changed files with 472 additions and 191 deletions
+2 -1
View File
@@ -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**
@@ -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);
}
/// <summary>One past the highest <c>sort_order</c> in use — across <b>both</b> tables, because
/// the sidebar interleaves folders and top-level lists in a single order sequence.</summary>
private async Task<int> NextSortOrderAsync(CancellationToken ct = default)
{
var maxList = await _context.Lists.MaxAsync(l => (int?)l.SortOrder, ct) ?? -1;
var maxFolder = await _context.ListFolders.MaxAsync(f => (int?)f.SortOrder, ct) ?? -1;
return Math.Max(maxList, maxFolder) + 1;
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
@@ -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<string> orderedListIds, CancellationToken ct = default)
/// <summary>
/// Writes the sidebar's visual order as one sequence spanning both tables: folders and lists
/// share the <c>sort_order</c> number space, which is what lets a top-level list sit between
/// two folders. A folder's own children follow it in the sequence, so ordering folders by
/// <c>sort_order</c> and lists by <c>sort_order</c> independently still reproduces the sidebar.
/// </summary>
public async Task ReorderSidebarAsync(IReadOnlyList<(string Id, bool IsFolder)> ordered, CancellationToken ct = default)
{
var 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);
}
+1 -1
View File
@@ -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<object>`: `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.
@@ -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<ListNavItemViewModel> Lists { get; } = new();
}
@@ -209,12 +209,19 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// <summary>Every user list, flat, regardless of folder — the set the count refresh, the
/// row refresh and the no-repo banner work over.</summary>
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
/// <summary>Sidebar folders, rendered above the top-level lists.</summary>
public ObservableCollection<ListFolderNavItemViewModel> Folders { get; } = new();
/// <summary>The user lists that sit outside any folder. Together with
/// <see cref="Folders"/> this is what the sidebar binds to; <see cref="UserLists"/> stays
/// the flat superset.</summary>
public ObservableCollection<ListNavItemViewModel> RootLists { get; } = new();
/// <summary>The sidebar's top level, in render order: folder rows
/// (<see cref="ListFolderNavItemViewModel"/>) and folder-less lists
/// (<see cref="ListNavItemViewModel"/>) interleaved, so a list can sit above a folder.
/// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary>
public ObservableCollection<object> TopLevel { get; } = new();
/// <summary>Sidebar folders in order — a projection of <see cref="TopLevel"/>, not a second
/// collection to keep in sync.</summary>
public IReadOnlyList<ListFolderNavItemViewModel> Folders =>
TopLevel.OfType<ListFolderNavItemViewModel>().ToList();
/// <summary>The user lists that sit outside any folder — likewise a projection.</summary>
public IReadOnlyList<ListNavItemViewModel> RootLists =>
TopLevel.OfType<ListNavItemViewModel>().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<string, ListFolderNavItemViewModel>();
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<string>(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)
/// <summary>Marks the insert position for a drop next to <paramref name="target"/> — either a
/// list row or a folder header (folders take the same two hint lines, drawn around the whole
/// folder block).</summary>
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;
}
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or
/// <see cref="RootLists"/> when it has no folder.</summary>
public ObservableCollection<ListNavItemViewModel> ContainerOf(ListNavItemViewModel row) =>
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? RootLists;
/// <see cref="TopLevel"/> when it has no folder. Non-generic because the top level is mixed.</summary>
public System.Collections.IList ContainerOf(ListNavItemViewModel row) =>
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? (System.Collections.IList)TopLevel;
/// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary>
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
DropAsync(source, target, placeBelow);
/// <summary>
/// Drops <paramref name="source"/> at <paramref name="target"/>'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 <paramref name="source"/> (a list row or a folder) at <paramref name="target"/>'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.
/// </summary>
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);
}
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is
@@ -533,10 +583,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// </summary>
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<ListNavItemViewModel> 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();
}
}
@@ -5,10 +5,13 @@
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
x:DataType="vm:ListsIslandViewModel">
<UserControl.Resources>
<!-- One user-list row. Shared by the top-level list and by each folder's children, so the
<!-- Implicit, type-matched templates: the sidebar's top level is one mixed ItemsControl over
TopLevel (folders and folder-less lists interleaved), and each folder's children resolve the
list template the same way. -->
<UserControl.DataTemplates>
<!-- One user-list row. Shared by the top level and by each folder's children, so the
row (context menu, drag handlers, drop indicators) exists once. -->
<DataTemplate x:Key="UserListRow" DataType="vm:ListNavItemViewModel">
<DataTemplate DataType="vm:ListNavItemViewModel">
<Grid RowDefinitions="Auto,Auto,Auto">
<!-- Above-row drop indicator -->
@@ -88,7 +91,65 @@
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate>
</UserControl.Resources>
<!-- One folder: header (never selectable — tapping folds it) plus its child lists. The two
drop indicators sit around the whole block, so "below this folder" doesn't draw a line
between the header and its children. -->
<DataTemplate DataType="vm:ListFolderNavItemViewModel">
<Grid RowDefinitions="Auto,Auto,Auto">
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<StackPanel Grid.Row="1">
<Border Classes="list-item"
Classes.drop-target="{Binding IsDropTarget}"
Tapped="OnFolderTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnFolderDragOver"
DragDrop.Drop="OnFolderDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextFolderSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFolderSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="12,20,*,Auto">
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding !IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="1" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding Lists}"
IsVisible="{Binding !IsCollapsed}"
Margin="14,0,0,0"/>
</StackPanel>
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate>
</UserControl.DataTemplates>
<DockPanel LastChildFill="True">
@@ -218,60 +279,9 @@
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
</Button>
</Grid>
<!-- Folders first, each with its own (collapsible) child lists, then the top-level lists.
Folders are never selectable — tapping the header folds it. -->
<ItemsControl ItemsSource="{Binding Folders}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ListFolderNavItemViewModel">
<StackPanel>
<Border Classes="list-item"
Classes.drop-target="{Binding IsDropTarget}"
Tapped="OnFolderTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnFolderDragOver"
DragDrop.Drop="OnFolderDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextFolderSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFolderSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="12,20,*,Auto">
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding !IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="1" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding Lists}"
IsVisible="{Binding !IsCollapsed}"
Margin="14,0,0,0"
ItemTemplate="{StaticResource UserListRow}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding RootLists}"
ItemTemplate="{StaticResource UserListRow}"/>
<!-- Folders and folder-less lists in one sequence, so either can be dragged above the
other; each item picks its template from UserControl.DataTemplates by type. -->
<ItemsControl ItemsSource="{Binding TopLevel}"/>
</StackPanel>
</ScrollViewer>
@@ -17,11 +17,28 @@ public partial class ListsIslandView : UserControl
{
private static readonly DataFormat<string> ListRowFormat =
DataFormat.CreateStringApplicationFormat("claudedo-list-row");
private static readonly DataFormat<string> 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.
/// <summary>The dragged row or folder, resolved from whichever of the two formats is present.</summary>
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 }
/// <summary>
/// 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.
/// </summary>
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);
@@ -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()
{
@@ -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]