Owned windows are pinned above their owner by Windows, so the main window could never come to the front. Show() without an owner and centre manually. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
216 lines
8.2 KiB
C#
216 lines
8.2 KiB
C#
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;
|
|
|
|
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<string, double> _descriptionHeights = new();
|
|
private DetailsIslandViewModel? _vm;
|
|
private DiffViewerView? _diffViewerWindow;
|
|
|
|
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<Rect>(_ => 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)>();
|
|
try
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
if (item is IStorageFile sf)
|
|
{
|
|
var stream = await sf.OpenReadAsync();
|
|
files.Add((sf.Name, stream));
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Opening the dropped stream can fail (shell IO, file lock) before AddFilesAsync
|
|
// ever runs; surface it with the exception type so the next occurrence is analyzable
|
|
// instead of vanishing as an unobserved async-void fault.
|
|
_vm.DropStatus = string.Format(
|
|
ClaudeDo.Ui.Localization.Loc.T("details.attachments.dropReadError"),
|
|
$"{ex.GetType().Name}: {ex.Message}");
|
|
foreach (var (_, s) in files) await s.DisposeAsync();
|
|
return;
|
|
}
|
|
|
|
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();
|
|
|
|
// Non-modal: the diff viewer must not block navigating the rest of the app.
|
|
// Reuse the one open window (swap its DataContext + bring to front) instead of
|
|
// stacking a second one when the user re-opens the diff for another task.
|
|
vm.Merge.ShowDiffViewer = (diffVm) =>
|
|
{
|
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
|
if (owner == null) return System.Threading.Tasks.Task.CompletedTask;
|
|
if (_diffViewerWindow is { } existing)
|
|
{
|
|
existing.DataContext = diffVm;
|
|
if (existing.WindowState == WindowState.Minimized)
|
|
existing.WindowState = WindowState.Normal;
|
|
existing.Activate();
|
|
return System.Threading.Tasks.Task.CompletedTask;
|
|
}
|
|
var window = new DiffViewerView { DataContext = diffVm };
|
|
window.Closed += (_, _) => _diffViewerWindow = null;
|
|
_diffViewerWindow = window;
|
|
window.CenterOn(owner);
|
|
window.Show(); // no owner: an owned window would be pinned above the main window
|
|
return System.Threading.Tasks.Task.CompletedTask;
|
|
};
|
|
|
|
// Non-modal so the main window stays usable while the merge + verify runs; the task
|
|
// completes on close so the diff viewer can still react to the outcome.
|
|
vm.Merge.ShowMergeModal = (mergeVm) =>
|
|
{
|
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
|
return owner == null ? System.Threading.Tasks.Task.CompletedTask : MergeModalView.ShowAsync(owner, mergeVm);
|
|
};
|
|
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
}
|