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
@@ -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);