diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 1618412e..c931d38d 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -729,7 +729,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable pane.Dispose(); } - // Mirrors ConPtySessions' add/remove 1:1 into Panes (typed as the pane abstraction). + /// Swaps two tiles' slots (overview-grid drag and drop); also reorders the focus-mode tabs. + public void SwapPanes(ConPtyPaneViewModel a, ConPtyPaneViewModel b) + { + var i = ConPtySessions.IndexOf(a); + var j = ConPtySessions.IndexOf(b); + if (i < 0 || j < 0 || i == j) return; + if (i > j) (i, j) = (j, i); + ConPtySessions.Move(i, j); + ConPtySessions.Move(j - 1, i); + } + + // Mirrors ConPtySessions' add/remove/move 1:1 into Panes (typed as the pane abstraction). private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) @@ -740,6 +751,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable case NotifyCollectionChangedAction.Remove: Panes.RemoveAt(e.OldStartingIndex); break; + case NotifyCollectionChangedAction.Move: + Panes.Move(e.OldStartingIndex, e.NewStartingIndex); + break; default: // Reset Panes.Clear(); break; diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml index 0ad5f5c7..fcc665d1 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml @@ -14,8 +14,8 @@ - - + diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index 8257f51b..1258bcbb 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -16,6 +16,16 @@ + + + + diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs index 5ddca48f..cd9cd5e5 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs @@ -4,12 +4,14 @@ using System.ComponentModel; using System.Linq; using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.VisualTree; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.ViewModels; +using ClaudeDo.Ui.ViewModels.MissionControl; namespace ClaudeDo.Ui.Views.MissionControl; @@ -33,6 +35,70 @@ public partial class MissionControlView : UserControl // Button runs its Command, and a synchronous Hide unbinds that Command first. RecentSessionsList.AddHandler(Button.ClickEvent, (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => RecentSessionsButton.Flyout?.Hide())); + OverviewGrid.AddHandler(PointerPressedEvent, OnTilePointerPressed); + OverviewGrid.AddHandler(PointerMovedEvent, OnTilePointerMoved); + OverviewGrid.AddHandler(PointerReleasedEvent, OnTilePointerReleased); + } + + private const double DragThreshold = 4; + + /// Tile presenter whose header was pressed; null when no tile drag is pending. + private ContentControl? _dragSource; + private Point _dragStart; + private bool _dragging; + private Border? _dropHint; + + // Buttons in the header mark PointerPressed handled, so only the bare header starts a drag. + private void OnTilePointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(OverviewGrid).Properties.IsLeftButtonPressed || e.Source is not Visual source) return; + var chain = source.GetSelfAndVisualAncestors().ToList(); + if (!chain.OfType().Any(b => b.Classes.Contains("pane-header"))) return; + _dragSource = chain.OfType().FirstOrDefault(c => ReferenceEquals(c.Parent, OverviewGrid)); + _dragStart = e.GetPosition(OverviewGrid); + } + + private void OnTilePointerMoved(object? sender, PointerEventArgs e) + { + if (_dragSource is null) return; + if (!e.GetCurrentPoint(OverviewGrid).Properties.IsLeftButtonPressed) { EndTileDrag(); return; } + + var pos = e.GetPosition(OverviewGrid); + if (!_dragging) + { + if (Math.Abs(pos.X - _dragStart.X) < DragThreshold && Math.Abs(pos.Y - _dragStart.Y) < DragThreshold) return; + _dragging = true; + _dragSource.Opacity = 0.5; + _dropHint = new Border { Classes = { "pane-drop-hint" }, IsVisible = false }; + OverviewGrid.Children.Add(_dropHint); + } + + var target = TileAt(pos); + _dropHint!.IsVisible = target is not null; + if (target is null) return; + Grid.SetColumn(_dropHint, Grid.GetColumn(target)); + Grid.SetRow(_dropHint, Grid.GetRow(target)); + } + + private void OnTilePointerReleased(object? sender, PointerReleasedEventArgs e) + { + var source = _dragSource; + var target = _dragging ? TileAt(e.GetPosition(OverviewGrid)) : null; + EndTileDrag(); + if (source?.Content is ConPtyPaneViewModel a && target?.Content is ConPtyPaneViewModel b) + _vm?.SwapPanes(a, b); + } + + private ContentControl? TileAt(Point pos) => OverviewGrid.Children.OfType() + .FirstOrDefault(c => !ReferenceEquals(c, _dragSource) && c.Bounds.Contains(pos)); + + private void EndTileDrag() + { + if (_dragSource is not null) _dragSource.Opacity = 1; + if (_dropHint is not null) OverviewGrid.Children.Remove(_dropHint); + _dragSource = null; + _dropHint = null; + _dragging = false; } private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync(); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index 686cbcd3..57279f33 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -491,6 +491,22 @@ public class MissionControlViewModelTests : IDisposable Assert.Equal(2, vm.ColumnCount); } + [Fact] + public async Task SwapPanes_SwapsSlots_InBothCollections() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + await vm.OpenConPtySessionAsync("t1"); + await vm.OpenConPtySessionAsync("t2"); + await vm.OpenConPtySessionAsync("t3"); + var (a, b, c) = (vm.Panes[0], vm.Panes[1], vm.Panes[2]); + + vm.SwapPanes(c, a); + + Assert.Equal(new[] { c, b, a }, vm.Panes); + Assert.Equal(new[] { c, b, a }, vm.ConPtySessions); + } + [Fact] public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists() {