Drop a file anywhere on the detail pane to attach it: pane-wide drop target with a 'Drop to attach' hover overlay (Copy cursor, gated on an idle selected task), an explicit lingering confirmation/error line, plus an Attachments list with size, remove, and an Add file… picker in the DETAILS card. ComposedPreview now shows the reference files too. en/de keys added.
280 lines
9.8 KiB
C#
280 lines
9.8 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;
|
|
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<string, double> _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<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)>();
|
|
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<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;
|
|
}
|
|
}
|