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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user