using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Reactive; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.Views.Modals; using ClaudeDo.Ui.Views.Planning; namespace ClaudeDo.Ui.Views.Islands; public partial class DetailsIslandView : UserControl { // Per-task description height (pixels) once the user drags the splitter. // Keyed by task id so each task keeps its own resize; tasks that were // never dragged stay dynamic (Auto-sized description). private readonly Dictionary _descriptionHeights = new(); private DetailsIslandViewModel? _vm; public DetailsIslandView() { InitializeComponent(); DataContextChanged += OnDataContextChanged; // Keep the row limits proportional to the island height: description // capped at 2/3, console floored at 1/3. The GridSplitter honours these // row Min/Max during a drag, so the console stops shrinking at 1/3. DetailBodyGrid.GetObservable(BoundsProperty) .Subscribe(new AnonymousObserver(_ => UpdateRowLimits())); AddHandler(DragDrop.DragEnterEvent, OnDragEnter); AddHandler(DragDrop.DragOverEvent, OnDragOver); AddHandler(DragDrop.DragLeaveEvent, OnDragLeave); AddHandler(DragDrop.DropEvent, OnDrop); } private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true; private void OnDragEnter(object? sender, DragEventArgs e) { if (_vm is { CanAcceptDrop: true } && IsFilesDrop(e)) { e.DragEffects = DragDropEffects.Copy; _vm.IsDragOver = true; } else { e.DragEffects = DragDropEffects.None; } e.Handled = true; } private void OnDragOver(object? sender, DragEventArgs e) { if (_vm is { CanAcceptDrop: true } && IsFilesDrop(e)) { e.DragEffects = DragDropEffects.Copy; _vm.IsDragOver = true; } else { e.DragEffects = DragDropEffects.None; } e.Handled = true; } private void OnDragLeave(object? sender, RoutedEventArgs e) { if (_vm != null) _vm.IsDragOver = false; } private async void OnDrop(object? sender, DragEventArgs e) { if (_vm != null) _vm.IsDragOver = false; if (_vm is not { CanAcceptDrop: true } || !IsFilesDrop(e)) return; e.Handled = true; var items = e.DataTransfer.TryGetFiles(); if (items is null) return; var files = new List<(string FileName, System.IO.Stream Content)>(); foreach (var item in items) { if (item is IStorageFile sf) { var stream = await sf.OpenReadAsync(); files.Add((sf.Name, stream)); } } if (files.Count == 0) return; try { await _vm.AddFilesAsync(files); } finally { foreach (var (_, s) in files) await s.DisposeAsync(); } } private void UpdateRowLimits() { var h = DetailBodyGrid.Bounds.Height; if (h <= 0) return; DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0; DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0; // The description sits in an Auto row, which measures its cell with // infinite height — so the card's inner ScrollViewer thinks everything // fits and never scrolls. Bounding the card itself gives that // ScrollViewer a finite measure constraint so it engages once the // content exceeds 2/3 of the island. (RowDefinition.MaxHeight above only // clamps the drag and the final row height, not the measure constraint.) DescriptionCard.MaxHeight = h * 2.0 / 3.0; } private void OnDataContextChanged(object? sender, EventArgs e) { if (_vm != null) _vm.PropertyChanged -= OnViewModelPropertyChanged; if (DataContext is DetailsIslandViewModel vm) { _vm = vm; vm.PropertyChanged += OnViewModelPropertyChanged; ApplyResizeStateForCurrentTask(); vm.Merge.ShowDiffModal = async (diffVm) => { var owner = TopLevel.GetTopLevel(this) as Window; if (owner == null) return; var modal = new DiffModalView { DataContext = diffVm }; await modal.ShowDialog(owner); }; vm.Merge.ShowMergeModal = async (mergeVm) => { var owner = TopLevel.GetTopLevel(this) as Window; if (owner == null) return; var modal = new MergeModalView { DataContext = mergeVm }; await modal.ShowDialog(owner); }; vm.Merge.ShowPlanningDiffModal = async (planningDiffVm) => { var owner = TopLevel.GetTopLevel(this) as Window; if (owner == null) return; var modal = new PlanningDiffView { DataContext = planningDiffVm }; await modal.ShowDialog(owner); }; vm.ConfirmAsync = ShowConfirmAsync; vm.ShowErrorAsync = ShowErrorDialogAsync; } } // Restores the resize state for the currently-selected task: a task the // user has dragged before gets its pinned pixel height (cap lifted); a task // never dragged falls back to dynamic sizing (Auto row + the bound cap). private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName == nameof(DetailsIslandViewModel.Task)) ApplyResizeStateForCurrentTask(); } private void ApplyResizeStateForCurrentTask() { // A task dragged before keeps its pixel height (clamped by the row's // 2/3 MaxHeight); a task never dragged stays Auto-sized. DetailBodyGrid.RowDefinitions[0].Height = _vm?.Task?.Id is string id && _descriptionHeights.TryGetValue(id, out var h) ? new GridLength(h, GridUnitType.Pixel) : GridLength.Auto; } // Pin the (until now Auto-sized) description row to its current pixel // height so the splitter resizes smoothly from there. private void OnSplitterDragStarted(object? sender, VectorEventArgs e) { var descRow = DetailBodyGrid.RowDefinitions[0]; if (descRow.Height.IsAuto) descRow.Height = new GridLength(DescriptionCard.Bounds.Height, GridUnitType.Pixel); } // Remember the dragged height for this task so switching tasks keeps each // task's resize independent. private void OnSplitterDragCompleted(object? sender, VectorEventArgs e) { if (_vm?.Task?.Id is string id) _descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value; } private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message) { var owner = TopLevel.GetTopLevel(this) as Window; if (owner == null) return; var ok = new Button { Content = "OK", MinWidth = 90 }; var dialog = new Window { Title = "Error", Width = 360, SizeToContent = SizeToContent.Height, CanResize = false, WindowStartupLocation = WindowStartupLocation.CenterOwner, ShowInTaskbar = false, Background = this.FindResource("SurfaceBrush") as IBrush, Content = new StackPanel { Spacing = 16, Margin = new Thickness(20), Children = { new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, Children = { ok } } } } }; ok.Click += (_, _) => dialog.Close(); await dialog.ShowDialog(owner); } private async System.Threading.Tasks.Task ShowConfirmAsync(string message) { var owner = TopLevel.GetTopLevel(this) as Window; if (owner == null) return false; var tcs = new TaskCompletionSource(); var cancel = new Button { Content = "Cancel", MinWidth = 90 }; var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } }; var dialog = new Window { Title = "Confirm", Width = 360, SizeToContent = SizeToContent.Height, CanResize = false, WindowStartupLocation = WindowStartupLocation.CenterOwner, ShowInTaskbar = false, Background = this.FindResource("SurfaceBrush") as IBrush, Content = new StackPanel { Spacing = 16, Margin = new Thickness(20), Children = { new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right, 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; } }