Files
ClaudeDo/src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs
T
mika kuns 589b9e75f3 fix(ui): unfreeze the operation spinner and attribute rebind churn
Three causes behind the stuck indicator:
- OperationStatus.End did not retire the generation, so tick work posted
  while the dispatcher was blocked drained afterwards and set
  ShowIndicator back to true on a finished operation.
- The startup update check ran in Task.Run; OperationStatus writes its
  observable properties on the calling thread, so the final
  ShowIndicator=false never reached the binding.
- OperationIndicator overwrote its own DataContext from the Status
  property, which re-targeted the call-site binding. It now scopes the
  DataContext to an inner panel and collapses itself when Status is
  null; every call site switched from DataContext= to Status=.

Also gates the footer connection pill in the command body instead of
CanExecute (a disabled command greyed out the ONLINE chip), and extends
OperationTiming: pid per line, 4 MB rollover, fast successes dropped
(failures always kept), and DetailsIsland.BindAsync now carries the
selection trigger via TasksIslandViewModel.SelectFrom.
2026-08-12 13:50:05 +02:00

369 lines
15 KiB
C#

using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Controls;
using ClaudeDo.Ui.Views.MissionControl;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands;
public partial class TasksIslandView : UserControl
{
private readonly TaskDragController _drag = new();
// Custom-drag gesture state. The drag is ARMED on press and BEGINS once the pointer moves
// past the threshold, so a plain click still selects the row.
private const double DragThreshold = 4;
private Point _pressPoint;
private TaskRowViewModel? _pressRow;
private Control? _pressControl;
private bool _dragArmed;
private bool _dragging;
// The list row (in the Lists island) currently highlighted as a drop target while dragging.
private ListNavItemViewModel? _hintedList;
public TasksIslandView()
{
InitializeComponent();
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
AddHandler(PointerMovedEvent, OnPointerMovedDrag, RoutingStrategies.Tunnel);
AddHandler(PointerReleasedEvent, OnPointerReleasedDrag, RoutingStrategies.Tunnel);
AddHandler(PointerCaptureLostEvent, OnPointerCaptureLost);
DataContextChanged += (_, _) =>
{
if (DataContext is TasksIslandViewModel vm)
{
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
vm.ShowUnfinishedPlanningModal = async (modalVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) { modalVm.CancelCommand.Execute(null); return; }
var modal = new UnfinishedPlanningModalView { DataContext = modalVm };
// Closing via the OS title-bar (if ever enabled) also resolves the TCS.
modal.Closed += (_, _) => modalVm.CancelCommand.Execute(null);
await modal.ShowDialog(owner);
// ShowDialog completes once the window is closed (CloseAction or OS close).
};
vm.ConfirmAsync = ShowConfirmAsync;
vm.SelectionChanged += (_, _) => ScrollSelectedIntoView();
}
};
}
// Bring the selected row into view — a programmatic select (e.g. Mission Control's "Open in
// app") can target a row that isn't currently realized. RowsListBox.ScrollIntoView is
// virtualization-aware (it realizes the container as needed); a visual-tree search for an
// existing Button, as this used to do, only finds rows already on screen.
private void ScrollSelectedIntoView()
{
if (DataContext is not TasksIslandViewModel vm || vm.SelectedTask is not { } target) return;
Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
// ── Custom ghost drag ────────────────────────────────────────────────────
// Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built
// drag (pointer capture + a transparent topmost ghost window) is the only way to get a
// translucent follower that crosses from this window into the separate Mission Control window.
private void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
ResetPressState();
if (DataContext is not TasksIslandViewModel) return;
if (e.Source is not Visual src) return;
var button = src as Button ?? src.FindAncestorOfType<Button>();
if (button?.DataContext is not TaskRowViewModel row) return;
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
// Select now so the details pane updates whether the gesture becomes a click or a drag.
if (DataContext is TasksIslandViewModel vm) vm.SelectFrom(row, "pointer-press");
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
// don't start a drag — that would capture the pointer and swallow the inner Click.
var nestedInsideButton = button.Parent is Visual parentVisual
&& parentVisual.FindAncestorOfType<Button>() is not null;
if (nestedInsideButton) return;
// Running tasks can be neither reordered nor re-queued.
if (row.IsRunning) return;
// Arm the drag for ANY list kind so drag-to-queue works everywhere; reorder-on-drop is
// still gated on CanReorder (user lists only).
_pressPoint = e.GetPosition(this);
_pressRow = row;
_pressControl = button;
_dragArmed = true;
}
private void OnPointerMovedDrag(object? sender, PointerEventArgs e)
{
if (!_dragArmed && !_dragging) return;
if (TopLevel.GetTopLevel(this) is not { } topLevel) return;
if (_dragArmed && !_dragging)
{
var p = e.GetPosition(this);
if (Math.Abs(p.X - _pressPoint.X) < DragThreshold && Math.Abs(p.Y - _pressPoint.Y) < DragThreshold)
return;
BeginDrag(e, topLevel);
}
if (_dragging)
{
_drag.MoveTo(this.PointToScreen(e.GetPosition(this)));
UpdateReorderHint(e, topLevel);
UpdateListDropHint(e, topLevel);
}
}
private void BeginDrag(PointerEventArgs e, TopLevel topLevel)
{
if (_pressControl is null || _pressRow is null) return;
// Snapshot the row BEFORE applying the "grabbed" style so the ghost stays crisp.
_drag.Begin(_pressControl, e.GetPosition(_pressControl), topLevel.RenderScaling);
_pressRow.IsDragging = true;
_dragging = true;
e.Pointer.Capture(this);
}
private async void OnPointerReleasedDrag(object? sender, PointerReleasedEventArgs e)
{
if (!_dragArmed && !_dragging) return;
var wasDragging = _dragging;
var row = _pressRow;
var topLevel = TopLevel.GetTopLevel(this);
var screen = wasDragging && topLevel is not null
? this.PointToScreen(e.GetPosition(this))
: default;
EndDrag(e);
if (!wasDragging || row is null || topLevel is null) return;
// 1) Released over the Mission Control window → queue the task.
if (MissionControlUnder(screen) is { } mc)
{
await mc.EnqueueTaskAsync(row.Id);
return;
}
// 2) Released over another user list in the Lists island → move the task there.
if (DataContext is TasksIslandViewModel vmMove
&& ListItemUnder(e, topLevel) is { } targetList
&& targetList.Kind == ListKind.User
&& targetList.Id != vmMove.CurrentListId)
{
await vmMove.MoveTaskToListAsync(row, targetList);
return;
}
// 3) Released over another row in the same user list → reorder.
if (DataContext is TasksIslandViewModel vm && vm.CanReorder)
{
var targetButton = RowButtonAt(e, topLevel);
if (targetButton?.DataContext is TaskRowViewModel target
&& !ReferenceEquals(target, row) && !target.IsRunning)
{
var placeBelow = e.GetPosition(targetButton).Y > targetButton.Bounds.Height / 2;
await vm.ReorderAsync(row, target, placeBelow);
return;
}
}
// 4) Anywhere else → cancel; EndDrag already restored the source row.
}
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
{
// We just took capture ourselves (stealing it from the row Button when the drag began) —
// that is not a real loss, so don't tear the drag down.
if (ReferenceEquals(e.Pointer.Captured, this)) return;
if (!_dragArmed && !_dragging) return;
if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
ClearListDropHint();
_drag.End();
ResetPressState();
}
private void EndDrag(PointerEventArgs e)
{
if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
ClearListDropHint();
_drag.End();
if (_dragging) e.Pointer.Capture(null);
ResetPressState();
}
private void ResetPressState()
{
_dragArmed = false;
_dragging = false;
_pressRow = null;
_pressControl = null;
}
// Live drop-hint while dragging over rows in the source (user) list.
private void UpdateReorderHint(PointerEventArgs e, TopLevel topLevel)
{
if (DataContext is not TasksIslandViewModel vm) return;
if (!vm.CanReorder) { vm.ClearDropHints(); return; }
var targetButton = RowButtonAt(e, topLevel);
if (targetButton?.DataContext is not TaskRowViewModel target
|| target.IsRunning || ReferenceEquals(target, _pressRow))
{
vm.ClearDropHints();
return;
}
var placeBelow = e.GetPosition(targetButton).Y > targetButton.Bounds.Height / 2;
// Canonicalize: "drop below X" == "drop above X+1". Render the indicator above X+1 when
// there is one; only the last row in a section shows a below-line.
TaskRowViewModel hintRow = target;
bool hintBelow = false;
if (placeBelow)
{
var next = FindNextInSameSection(vm, target);
if (next is not null && !next.IsRunning) { hintRow = next; hintBelow = false; }
else { hintRow = target; hintBelow = true; }
}
// A hint that lands right where the dragged row already sits is a no-op.
if (_pressRow is not null && hintRow.Id == _pressRow.Id) { vm.ClearDropHints(); return; }
vm.SetDropHint(hintRow, hintBelow);
}
// The row-level Button under the cursor, found by geometric hit-test on the source window
// (works while the pointer is captured to this control).
private static Button? RowButtonAt(PointerEventArgs e, TopLevel topLevel)
{
var pt = e.GetPosition((Visual)topLevel);
if (topLevel.InputHitTest(pt) is not Visual hit) return null;
var button = hit as Button ?? hit.FindAncestorOfType<Button>();
while (button is not null && button.DataContext is not TaskRowViewModel)
button = (button.Parent as Visual)?.FindAncestorOfType<Button>();
return button?.DataContext is TaskRowViewModel ? button : null;
}
// The list-row Border under the cursor (Lists island), found the same way as RowButtonAt but
// walking up to a Border with the "list-item" style class (mirrors ListsIslandView's own
// FindListItemBorder) — the task drag has the pointer captured here, so a DragEventArgs never
// reaches the Lists island's own DragDrop.DragOver/Drop handlers.
private static ListNavItemViewModel? ListItemUnder(PointerEventArgs e, TopLevel topLevel)
{
var pt = e.GetPosition((Visual)topLevel);
Visual? v = topLevel.InputHitTest(pt) as Visual;
while (v is not null)
{
if (v is Border b && b.Classes.Contains("list-item"))
return b.DataContext as ListNavItemViewModel;
v = v.GetVisualParent();
}
return null;
}
// Live drop-hint while dragging over a candidate target list row.
private void UpdateListDropHint(PointerEventArgs e, TopLevel topLevel)
{
if (DataContext is not TasksIslandViewModel vm) { ClearListDropHint(); return; }
var target = ListItemUnder(e, topLevel);
if (target is null || target.Kind != ListKind.User || target.Id == vm.CurrentListId)
target = null;
if (ReferenceEquals(_hintedList, target)) return;
if (_hintedList is not null) _hintedList.IsTaskDropTarget = false;
_hintedList = target;
if (_hintedList is not null) _hintedList.IsTaskDropTarget = true;
}
private void ClearListDropHint()
{
if (_hintedList is not null) _hintedList.IsTaskDropTarget = false;
_hintedList = null;
}
// The Mission Control view model whose window contains the release point, if any.
private static MissionControlViewModel? MissionControlUnder(PixelPoint screen)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return null;
foreach (var w in desktop.Windows)
{
if (w is not MissionControlWindow mc || !mc.IsVisible) continue;
if (DragHitTest.WindowContains(mc.Position, mc.ClientSize, mc.RenderScaling, screen))
return mc.DataContext as MissionControlViewModel;
}
return null;
}
// The next row in Rows is only "in the same section" while it's a TaskRowViewModel —
// hitting a HeaderRow (or the end of the list) means row was the last one in its section.
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
{
var idx = vm.Rows.IndexOf(row);
if (idx < 0 || idx + 1 >= vm.Rows.Count) return null;
return vm.Rows[idx + 1] as TaskRowViewModel;
}
}