Files
ClaudeDo/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml.cs
Mika Kuns d8ff8cc110 feat(attachments): drag-and-drop file attachments on the detail pane
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.
2026-06-26 16:11:48 +02:00

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