Folders render above the top-level lists, each with its own collapsible children; tapping a folder header folds it instead of selecting it. Lists move in by dragging onto a folder header (or onto a row inside it) and out by dropping between top-level rows or via the context menu. Creating a folder is a toggle in the New-list modal, not a third sidebar button: it reduces the modal to name + icon and writes a folder row instead of the list row the create flow pre-created.
218 lines
7.4 KiB
C#
218 lines
7.4 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");
|
|
|
|
public ListsIslandView()
|
|
{
|
|
InitializeComponent();
|
|
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, 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?.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
|
|
if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) 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 void OnFolderTapped(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is Border { DataContext: ListFolderNavItemViewModel folder }
|
|
&& DataContext is ListsIslandViewModel vm)
|
|
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.
|
|
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 })
|
|
{
|
|
e.DragEffects = DragDropEffects.None;
|
|
vm.ClearDropHints();
|
|
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;
|
|
}
|
|
|
|
vm.SetFolderDropTarget(folder);
|
|
e.DragEffects = DragDropEffects.Move;
|
|
}
|
|
|
|
private async void OnFolderDrop(object? sender, DragEventArgs e)
|
|
{
|
|
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 (source is null) return;
|
|
|
|
vm.ClearDropHints();
|
|
await vm.MoveToFolderAsync(source, folder);
|
|
}
|
|
catch
|
|
{
|
|
vm.ClearDropHints();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
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();
|
|
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;
|
|
|
|
// Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line.
|
|
ListNavItemViewModel hintRow = target;
|
|
bool hintBelow = false;
|
|
if (placeBelow)
|
|
{
|
|
var next = FindNextUserList(vm, target);
|
|
if (next is not null) { hintRow = next; hintBelow = false; }
|
|
else { hintRow = target; hintBelow = true; }
|
|
}
|
|
|
|
if (hintRow.Id == sourceId)
|
|
{
|
|
e.DragEffects = DragDropEffects.None;
|
|
vm.ClearDropHints();
|
|
return;
|
|
}
|
|
|
|
vm.SetDropHint(hintRow, 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 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 placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
|
|
vm.ClearDropHints();
|
|
await vm.ReorderAsync(source, target, placeBelow);
|
|
}
|
|
catch
|
|
{
|
|
vm.ClearDropHints();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
{
|
|
var container = vm.ContainerOf(row);
|
|
var idx = container.IndexOf(row);
|
|
if (idx < 0) return null;
|
|
return idx + 1 < container.Count ? container[idx + 1] : null;
|
|
}
|
|
|
|
}
|