Files
ClaudeDo/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
T
mika kunsandClaude Sonnet 5.5 0fea339b6d fix(ui): open diff viewer and merge window as independent top-level windows
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>
2026-10-01 14:49:27 +02:00

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;
}
}