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.
295 lines
11 KiB
C#
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;
|
|
}
|
|
|
|
}
|