Files
ClaudeDo/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs
T
Mika KunsandClaude Sonnet 5 0dac047c46 feat(lists): allow nested sidebar folders via drag and drop
Folders can now contain other folders, to arbitrary depth, via a
self-referencing parent_folder_id (ON DELETE SET NULL, same pattern as
lists.folder_id). Nesting/un-nesting is drag-and-drop only: dropping a
folder onto another folder's header middle moves it in, the outer
quarters or any top-level/in-folder drop insert it beside a target.
Dropping a folder into itself or one of its own subfolders is silently
ignored. Deleting a folder releases its lists and subfolders back to
the top level, unchanged from the existing list behavior.

ListFolderNavItemViewModel.Lists became Children (object, mixing lists
and subfolders, mirroring TopLevel) so the existing implicit, type-matched
DataTemplates render nested folders recursively without new XAML, and each
nesting level gets its indent for free from the folder template's own
margin. Folders/RootLists/ContainerOf/PersistOrderAsync/RecomputeFolderCounts
now walk the tree instead of assuming one level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:31:26 +02:00

303 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: the middle half moves the source *into* the folder
/// (the only route into a collapsed or empty one) while the outer quarters insert next to the
/// folder in its own container. A folder dropped onto itself or one of its own subfolders has
/// no valid "into" or "beside" and is rejected.
/// </summary>
private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop(
object source, ListFolderNavItemViewModel folder, double y, double height)
{
if (source is ListFolderNavItemViewModel dragged)
{
if (IsSelfOrDescendant(dragged, folder)) return (FolderDrop.None, false);
if (y < height * 0.25) return (FolderDrop.Beside, false);
if (y > height * 0.75) return (FolderDrop.Beside, true);
return dragged.ParentFolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
}
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 static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, ListFolderNavItemViewModel candidate)
{
if (ReferenceEquals(folder, candidate)) return true;
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
if (IsSelfOrDescendant(child, candidate)) return true;
return 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)
await vm.MoveToFolderAsync(source, 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; }
// 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;
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;
}
}