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.
This commit is contained in:
@@ -161,6 +161,58 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Attachments section -->
|
||||
<Border BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="0,8,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.attachments.sectionLabel}"/>
|
||||
|
||||
<!-- Attachment rows -->
|
||||
<ItemsControl ItemsSource="{Binding Attachments}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:AttachmentRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2,0,2">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding FileName}"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding SizeText}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn"
|
||||
ToolTip.Tip="{loc:Tr details.attachments.removeTip}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).RemoveAttachmentCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<PathIcon Data="{StaticResource Icon.X}" Width="11" Height="11"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Add file button -->
|
||||
<Button Classes="btn"
|
||||
Padding="8,3"
|
||||
HorizontalAlignment="Left"
|
||||
Click="OnAddFileClick"
|
||||
Content="{loc:Tr details.attachments.addFile}"/>
|
||||
|
||||
<!-- Drop status / confirmation -->
|
||||
<TextBlock Text="{Binding DropStatus}"
|
||||
IsVisible="{Binding DropStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
|
||||
@@ -2,6 +2,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
||||
@@ -34,4 +35,32 @@ public partial class DescriptionStepsCard : UserControl
|
||||
&& vm.CommitSubtaskEditCommand.CanExecute(row))
|
||||
vm.CommitSubtaskEditCommand.Execute(row);
|
||||
}
|
||||
|
||||
private async void OnAddFileClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null) return;
|
||||
|
||||
var picked = await topLevel.StorageProvider.OpenFilePickerAsync(
|
||||
new FilePickerOpenOptions { AllowMultiple = true });
|
||||
|
||||
if (picked.Count == 0) return;
|
||||
|
||||
var files = new List<(string FileName, System.IO.Stream Content)>();
|
||||
foreach (var item in picked)
|
||||
{
|
||||
var stream = await item.OpenReadAsync();
|
||||
files.Add((item.Name, stream));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await vm.AddFilesAsync(files);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var (_, s) in files) await s.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
||||
x:DataType="vm:DetailsIslandViewModel">
|
||||
x:DataType="vm:DetailsIslandViewModel"
|
||||
DragDrop.AllowDrop="True">
|
||||
<Panel>
|
||||
<DockPanel>
|
||||
|
||||
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
|
||||
@@ -124,4 +126,21 @@
|
||||
</Grid>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
<!-- Drop overlay — shown while dragging files over the pane -->
|
||||
<Border IsVisible="{Binding IsDragOver}"
|
||||
Background="{DynamicResource AccentSoftBrush}"
|
||||
BorderBrush="{DynamicResource AccentBrush}"
|
||||
BorderThickness="2"
|
||||
CornerRadius="14"
|
||||
IsHitTestVisible="False">
|
||||
<TextBlock Text="{loc:Tr details.attachments.dropToAttach}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AccentBrush}"
|
||||
FontSize="16"
|
||||
FontWeight="Medium"/>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
@@ -27,6 +29,78 @@ public partial class DetailsIslandView : UserControl
|
||||
// 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()
|
||||
|
||||
Reference in New Issue
Block a user