feat(mission-control): swap tiles by dragging their header

This commit is contained in:
mika kuns
2026-10-01 09:07:02 +02:00
parent 16adcad84f
commit 97af85c816
5 changed files with 109 additions and 3 deletions
@@ -729,7 +729,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.Dispose(); pane.Dispose();
} }
// Mirrors ConPtySessions' add/remove 1:1 into Panes (typed as the pane abstraction). /// <summary>Swaps two tiles' slots (overview-grid drag and drop); also reorders the focus-mode tabs.</summary>
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) private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
switch (e.Action) switch (e.Action)
@@ -740,6 +751,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
case NotifyCollectionChangedAction.Remove: case NotifyCollectionChangedAction.Remove:
Panes.RemoveAt(e.OldStartingIndex); Panes.RemoveAt(e.OldStartingIndex);
break; break;
case NotifyCollectionChangedAction.Move:
Panes.Move(e.OldStartingIndex, e.NewStartingIndex);
break;
default: // Reset default: // Reset
Panes.Clear(); Panes.Clear();
break; break;
@@ -14,8 +14,8 @@
<Border DockPanel.Dock="Left" Width="3" <Border DockPanel.Dock="Left" Width="3"
Background="{Binding IdentityColorKey, Converter={x:Static cconv:DotBrushConverter.Instance}}" /> Background="{Binding IdentityColorKey, Converter={x:Static cconv:DotBrushConverter.Instance}}" />
<!-- Header: title + close --> <!-- Header: title + close; also the drag handle for reordering tiles (MissionControlView) -->
<Border DockPanel.Dock="Top" <Border DockPanel.Dock="Top" Classes="pane-header" Cursor="SizeAll"
Background="{DynamicResource Surface2Brush}" Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1" Padding="8,3"> BorderThickness="0,0,0,1" Padding="8,3">
@@ -16,6 +16,16 @@
<mc:ConPtyPaneHost Margin="6" /> <mc:ConPtyPaneHost Margin="6" />
</DataTemplate> </DataTemplate>
</UserControl.DataTemplates> </UserControl.DataTemplates>
<UserControl.Styles>
<!-- Drop target outline while dragging a tile by its header; matches the pane's Margin/CornerRadius. -->
<Style Selector="Border.pane-drop-hint">
<Setter Property="Margin" Value="6" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
<Setter Property="IsHitTestVisible" Value="False" />
</Style>
</UserControl.Styles>
<DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}"> <DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}">
<!-- Header --> <!-- Header -->
@@ -4,12 +4,14 @@ using System.ComponentModel;
using System.Linq; using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.MissionControl;
namespace ClaudeDo.Ui.Views.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. // Button runs its Command, and a synchronous Hide unbinds that Command first.
RecentSessionsList.AddHandler(Button.ClickEvent, RecentSessionsList.AddHandler(Button.ClickEvent,
(_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => RecentSessionsButton.Flyout?.Hide())); (_, _) => 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;
/// <summary>Tile presenter whose header was pressed; null when no tile drag is pending.</summary>
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<Border>().Any(b => b.Classes.Contains("pane-header"))) return;
_dragSource = chain.OfType<ContentControl>().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<ContentControl>()
.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(); private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
@@ -491,6 +491,22 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(2, vm.ColumnCount); 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] [Fact]
public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists() public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists()
{ {