Programs are configured in Settings → Custom tiles and stored in ui.config.json. A tile with a fixed folder starts on click; without one it offers a submenu of home, the list folders and browse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
14 KiB
C#
352 lines
14 KiB
C#
using System;
|
|
using System.Collections.Specialized;
|
|
using System.ComponentModel;
|
|
using System.Linq;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
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;
|
|
|
|
public partial class MissionControlView : UserControl
|
|
{
|
|
private const double SplitterSize = 6;
|
|
private const double DefaultQueueWidth = 210;
|
|
private const double MinQueueWidth = 160;
|
|
private const double MinPaneWidth = 220;
|
|
private const double MinPaneHeight = 140;
|
|
|
|
private MissionControlViewModel? _vm;
|
|
private GridLength _queueWidth = new(DefaultQueueWidth, GridUnitType.Pixel);
|
|
|
|
public MissionControlView()
|
|
{
|
|
InitializeComponent();
|
|
DataContextChanged += OnDataContextChanged;
|
|
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
|
|
// Resume/Brief buttons don't close a plain Flyout on their own. Deferred: Click fires before
|
|
// 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);
|
|
}
|
|
|
|
// Inline rename in the Recent sessions flyout. Deliberately not Buttons: the Click handler above would hide the flyout.
|
|
private void OnSessionTitleDoubleTapped(object? sender, TappedEventArgs e)
|
|
{
|
|
if (sender is not TextBlock { DataContext: RecentSessionViewModel row, Parent: Panel panel }) return;
|
|
row.BeginEdit();
|
|
var box = panel.Children.OfType<TextBox>().FirstOrDefault();
|
|
// Deferred: the TextBox only becomes visible (focusable) after the binding update.
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => { box?.Focus(); box?.SelectAll(); });
|
|
}
|
|
|
|
private void OnSessionTitleEditKeyDown(object? sender, KeyEventArgs e)
|
|
{
|
|
if (sender is not TextBox { DataContext: RecentSessionViewModel row }) return;
|
|
if (e.Key == Key.Enter) row.CommitRenameCommand.Execute(null);
|
|
else if (e.Key == Key.Escape) row.CancelEdit();
|
|
else return;
|
|
e.Handled = true; // Escape would otherwise close the flyout too
|
|
}
|
|
|
|
private void OnSessionTitleEditLostFocus(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is TextBox { DataContext: RecentSessionViewModel row })
|
|
row.CommitRenameCommand.Execute(null);
|
|
}
|
|
|
|
// Ticks the "Running now" elapsed times while the view is on screen.
|
|
private Avalonia.Threading.DispatcherTimer? _elapsedTimer;
|
|
|
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
|
{
|
|
base.OnAttachedToVisualTree(e);
|
|
_elapsedTimer = new Avalonia.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
|
_elapsedTimer.Tick += (_, _) => _vm?.TickOperations();
|
|
_elapsedTimer.Start();
|
|
}
|
|
|
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
|
{
|
|
_elapsedTimer?.Stop();
|
|
_elapsedTimer = null;
|
|
base.OnDetachedFromVisualTree(e);
|
|
}
|
|
|
|
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 OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync();
|
|
|
|
// The view owns the folder picker (it needs a TopLevel); the VM decides what to open with the path.
|
|
private async System.Threading.Tasks.Task<string?> PickFolderAsync()
|
|
{
|
|
var topLevel = TopLevel.GetTopLevel(this);
|
|
if (topLevel is null) return null;
|
|
|
|
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
|
{
|
|
Title = "Choose a directory",
|
|
AllowMultiple = false,
|
|
});
|
|
return folders.Count == 0 ? null : folders[0].Path.LocalPath;
|
|
}
|
|
|
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
|
{
|
|
if (_vm is not null)
|
|
{
|
|
_vm.Panes.CollectionChanged -= OnPanesChanged;
|
|
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
|
_vm.PickFolderAsync = null;
|
|
_vm.ConfirmAsync = null;
|
|
}
|
|
|
|
_vm = DataContext as MissionControlViewModel;
|
|
|
|
if (_vm is not null)
|
|
{
|
|
_vm.Panes.CollectionChanged += OnPanesChanged;
|
|
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
|
_vm.PickFolderAsync = PickFolderAsync;
|
|
_vm.ConfirmAsync = ShowConfirmAsync;
|
|
}
|
|
|
|
RebuildOverviewGrid();
|
|
UpdateQueueColumn();
|
|
}
|
|
|
|
// Abandon-run confirmation -- own plain-Window TaskCompletionSource dialog, not routed
|
|
// through WindowDialogService: it's owned by the main window, so showing it while Mission
|
|
// Control (a separate Window) is focused would put it behind Mission Control.
|
|
private async 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 = Loc.T("dialog.cancel"), MinWidth = 90 };
|
|
var confirm = new Button { Content = Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
|
|
|
|
var dialog = new Window
|
|
{
|
|
Title = Loc.T("dialog.confirmTitle"),
|
|
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;
|
|
}
|
|
|
|
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildOverviewGrid();
|
|
|
|
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(MissionControlViewModel.ColumnCount))
|
|
RebuildOverviewGrid();
|
|
else if (e.PropertyName == nameof(MissionControlViewModel.HasActivity))
|
|
UpdateQueueColumn();
|
|
else if (e.PropertyName == nameof(MissionControlViewModel.IsFocusMode))
|
|
// The now-visible layout's hosts must re-steal their pane views from the hidden
|
|
// one (see ConPtyPaneHost) — posted so it runs after the visibility change and
|
|
// the TabControl's content presenter have gone through a layout pass.
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(ReclaimVisiblePaneHosts,
|
|
Avalonia.Threading.DispatcherPriority.Loaded);
|
|
}
|
|
|
|
private void ReclaimVisiblePaneHosts()
|
|
{
|
|
foreach (var host in this.GetVisualDescendants().OfType<ConPtyPaneHost>())
|
|
host.Reclaim();
|
|
}
|
|
|
|
// The queue column is Pixel-widthed (so the splitter can drag it) — when nothing is
|
|
// queued it collapses to 0 instead of leaving a blank gap, and remembers the last
|
|
// dragged width for the rest of this window's lifetime (not persisted beyond it).
|
|
private void UpdateQueueColumn()
|
|
{
|
|
var queueColumn = BodyGrid.ColumnDefinitions[2];
|
|
if (_vm?.HasActivity == true)
|
|
{
|
|
queueColumn.MinWidth = MinQueueWidth;
|
|
queueColumn.Width = _queueWidth;
|
|
}
|
|
else
|
|
{
|
|
queueColumn.MinWidth = 0;
|
|
queueColumn.Width = new GridLength(0, GridUnitType.Pixel);
|
|
}
|
|
}
|
|
|
|
// Replaces the old UniformGrid with a real Grid + GridSplitters so panes resize
|
|
// individually. Column splitters span every row (resize that whole column vs. its
|
|
// neighbor); row splitters span every column. Rebuilt from scratch whenever the pane
|
|
// count or column count changes — per-pane sizes are intentionally not preserved across
|
|
// a rebuild (session-only resizing, reset to the default split when panes come/go).
|
|
private void RebuildOverviewGrid()
|
|
{
|
|
OverviewGrid.Children.Clear();
|
|
OverviewGrid.ColumnDefinitions.Clear();
|
|
OverviewGrid.RowDefinitions.Clear();
|
|
|
|
var panes = _vm?.Panes;
|
|
if (panes is not { Count: > 0 }) return;
|
|
|
|
var columns = Math.Max(1, _vm!.ColumnCount);
|
|
var rows = (int)Math.Ceiling(panes.Count / (double)columns);
|
|
|
|
for (var c = 0; c < columns; c++)
|
|
{
|
|
if (c > 0) OverviewGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
|
|
OverviewGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)) { MinWidth = MinPaneWidth });
|
|
}
|
|
|
|
for (var r = 0; r < rows; r++)
|
|
{
|
|
if (r > 0) OverviewGrid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
|
|
OverviewGrid.RowDefinitions.Add(new RowDefinition(new GridLength(1, GridUnitType.Star)) { MinHeight = MinPaneHeight });
|
|
}
|
|
|
|
var totalGridRows = rows * 2 - 1;
|
|
var totalGridCols = columns * 2 - 1;
|
|
|
|
for (var c = 1; c < columns; c++)
|
|
{
|
|
var splitter = new GridSplitter
|
|
{
|
|
Width = SplitterSize,
|
|
Background = Brushes.Transparent,
|
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
VerticalAlignment = VerticalAlignment.Stretch,
|
|
ResizeDirection = GridResizeDirection.Columns,
|
|
ResizeBehavior = GridResizeBehavior.PreviousAndNext,
|
|
};
|
|
Grid.SetColumn(splitter, c * 2 - 1);
|
|
Grid.SetRow(splitter, 0);
|
|
Grid.SetRowSpan(splitter, totalGridRows);
|
|
OverviewGrid.Children.Add(splitter);
|
|
}
|
|
|
|
for (var r = 1; r < rows; r++)
|
|
{
|
|
var splitter = new GridSplitter
|
|
{
|
|
Height = SplitterSize,
|
|
Background = Brushes.Transparent,
|
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
VerticalAlignment = VerticalAlignment.Stretch,
|
|
ResizeDirection = GridResizeDirection.Rows,
|
|
ResizeBehavior = GridResizeBehavior.PreviousAndNext,
|
|
};
|
|
Grid.SetColumn(splitter, 0);
|
|
Grid.SetColumnSpan(splitter, totalGridCols);
|
|
Grid.SetRow(splitter, r * 2 - 1);
|
|
OverviewGrid.Children.Add(splitter);
|
|
}
|
|
|
|
for (var i = 0; i < panes.Count; i++)
|
|
{
|
|
var presenter = new ContentControl { Content = panes[i] };
|
|
Grid.SetColumn(presenter, (i % columns) * 2);
|
|
Grid.SetRow(presenter, (i / columns) * 2);
|
|
OverviewGrid.Children.Add(presenter);
|
|
}
|
|
}
|
|
}
|