Files
ClaudeDo/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs
T
mika kuns bfb1c6523c 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.
2026-09-10 15:40:10 +02:00

295 lines
11 KiB
C#

using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands;
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)
vm.FocusSearchRequested += (_, _) => SearchBox.Focus();
};
}
private void OnItemTapped(object? sender, RoutedEventArgs e)
{
if (sender is Border { DataContext: ListNavItemViewModel item }
&& DataContext is ListsIslandViewModel vm)
vm.SelectCommand.Execute(item);
}
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) return;
if (e.Source is not Visual src) return;
var border = FindListItemBorder(src);
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)
{
vm.OpenListSettingsCommand.Execute(row);
return;
}
// Select now so the right pane updates whether the gesture becomes a click or a drag
// (the Tapped handler doesn't fire once DoDragDropAsync captures the pointer).
vm.SelectCommand.Execute(row);
var data = new DataTransfer();
data.Add(DataTransferItem.Create(ListRowFormat, row.Id));
try
{
await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
}
finally
{
vm.ClearDropHints();
}
}
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 }
&& DataContext is ListsIslandViewModel vm)
vm.ToggleFolderCommand.Execute(folder);
}
/// <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 (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder)
{
Reject(vm, e);
return;
}
var source = DragSource(vm, e);
if (source is null) { Reject(vm, e); return; }
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)
{
if (DataContext is not ListsIslandViewModel vm) return;
try
{
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();
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
{
vm.ClearDropHints();
throw;
}
}
private void OnListDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User)
{
Reject(vm, e);
return;
}
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.
object hintTarget = target;
var hintBelow = false;
if (e.GetPosition(b).Y > b.Bounds.Height / 2)
{
if (FindNextInContainer(vm, target) is { } next) hintTarget = next;
else hintBelow = true;
}
if (ReferenceEquals(hintTarget, source)) { Reject(vm, e); return; }
vm.SetDropHint(hintTarget, hintBelow);
e.DragEffects = DragDropEffects.Move;
}
private async void OnListDrop(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) return;
try
{
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) 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.DropAsync(source, target, placeBelow);
}
catch
{
vm.ClearDropHints();
throw;
}
}
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)
{
if (v is Border b && b.Classes.Contains("list-item")) return b;
v = v.GetVisualParent();
}
return null;
}
// 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);
if (idx < 0) return null;
return idx + 1 < container.Count ? container[idx + 1] : null;
}
}