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().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; /// 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(); 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 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 ShowConfirmAsync(string message) { var owner = TopLevel.GetTopLevel(this) as Window; if (owner is null) return false; var tcs = new TaskCompletionSource(); 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()) 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); } } }