using System; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.VisualTree; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.ViewModels; 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. RecentSessionsList.AddHandler(Button.ClickEvent, (_, _) => RecentSessionsButton.Flyout?.Hide()); } private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync(); private void OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync(); // Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path. private async void BrowseForSessionAsync() { if (DataContext is not MissionControlViewModel vm) return; var topLevel = TopLevel.GetTopLevel(this); if (topLevel is null) return; var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Choose a directory", AllowMultiple = false, }); if (folders.Count == 0) return; await vm.OpenAdHocConPtySessionAsync(folders[0].Path.LocalPath); } private void OnDataContextChanged(object? sender, EventArgs e) { if (_vm is not null) { _vm.Panes.CollectionChanged -= OnPanesChanged; _vm.PropertyChanged -= OnViewModelPropertyChanged; _vm.BrowseForSessionRequested = null; _vm.ConfirmAsync = null; } _vm = DataContext as MissionControlViewModel; if (_vm is not null) { _vm.Panes.CollectionChanged += OnPanesChanged; _vm.PropertyChanged += OnViewModelPropertyChanged; _vm.BrowseForSessionRequested = BrowseForSessionAsync; _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.HasQueued)) 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?.HasQueued == 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); } } }