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.
369 lines
15 KiB
C#
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;
|
|
}
|
|
}
|