The diff viewer used ShowDialog at both call sites (task details, worktrees overview), blocking navigation of the rest of the app while open. Switch both to Window.Show(owner) and reuse the one open window (swap DataContext, Activate()) instead of stacking a second one on re-open. Add an opt-in maximize/restore button + double-click-titlebar toggle to ModalShell (ShowMaximizeButton, default false so other modals are untouched), wired on for DiffViewerView only. Reuses the existing Icon.WinMax/WinRestore geometry and OffScreenMargin inset from MainWindow's own maximize handling. The review gate (Approve & Merge locked until the diff is opened) already fires DiffViewed before the dialog/window is shown, so it is unaffected by the modal-to-non-modal change.
300 lines
11 KiB
C#
300 lines
11 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.Show(owner);
|
|
return System.Threading.Tasks.Task.CompletedTask;
|
|
};
|
|
|
|
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.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<bool> ShowConfirmAsync(string message)
|
|
{
|
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
|
if (owner == null) return false;
|
|
|
|
var tcs = new TaskCompletionSource<bool>();
|
|
|
|
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;
|
|
}
|
|
}
|