feat(claude-do): merge Pasting a clipboard image into the task description attaches
ClaudeDo-Task: 46c733e18d6c4bbb8742d0a1b0e3d829
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>Reads a bitmap off the clipboard; shared by the terminal view and the description editor.</summary>
|
||||
public static class ClipboardBitmap
|
||||
{
|
||||
/// <summary>The clipboard bitmap (caller disposes), or null if there is none or the read failed.</summary>
|
||||
public static async Task<Bitmap?> TryGetAsync(TopLevel? topLevel)
|
||||
{
|
||||
if (topLevel?.Clipboard is not { } clipboard) return null;
|
||||
try
|
||||
{
|
||||
using var data = await clipboard.TryGetDataAsync();
|
||||
return data is null ? null : await AsyncDataTransferExtensions.TryGetBitmapAsync(data);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,11 @@ public static class ClipboardImageTempStore
|
||||
|
||||
/// <summary>Test seam for <see cref="NewPngPath(DateTime)"/> — takes the target dir explicitly
|
||||
/// instead of the real app data root.</summary>
|
||||
internal static string NewPngPath(string dir, DateTime utcNow) =>
|
||||
Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png");
|
||||
internal static string NewPngPath(string dir, DateTime utcNow) => Path.Combine(dir, NewPngFileName(utcNow));
|
||||
|
||||
/// <summary>A fresh, collision-free <c>paste-….png</c> file name (also used as an attachment name).</summary>
|
||||
public static string NewPngFileName(DateTime utcNow) =>
|
||||
$"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png";
|
||||
|
||||
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
|
||||
/// explicitly instead of the real app data root.</summary>
|
||||
|
||||
@@ -67,7 +67,7 @@ public partial class InteractiveTerminalView : UserControl
|
||||
Avalonia.Media.Imaging.Bitmap? bitmap = null;
|
||||
try
|
||||
{
|
||||
bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
|
||||
bitmap = await ClipboardBitmap.TryGetAsync(TopLevel.GetTopLevel(view));
|
||||
if (bitmap is null)
|
||||
{
|
||||
if (!await vm.PasteAsync()) vm.ReportSendFailed();
|
||||
@@ -90,20 +90,6 @@ public partial class InteractiveTerminalView : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task<Avalonia.Media.Imaging.Bitmap?> TryGetClipboardBitmapAsync(TopLevel? topLevel)
|
||||
{
|
||||
if (topLevel?.Clipboard is not { } clipboard) return null;
|
||||
try
|
||||
{
|
||||
using var data = await clipboard.TryGetDataAsync();
|
||||
return data is null ? null : await Avalonia.Input.AsyncDataTransferExtensions.TryGetBitmapAsync(data);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
|
||||
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
<!-- Description -->
|
||||
<Panel IsVisible="{Binding IsDescriptionSection}">
|
||||
<!-- Edit mode: raw TextBox -->
|
||||
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
|
||||
<TextBox x:Name="DescriptionBox"
|
||||
Text="{Binding EditableDescription, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="80"
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
||||
@@ -12,6 +13,41 @@ public partial class DescriptionStepsCard : UserControl
|
||||
public DescriptionStepsCard()
|
||||
{
|
||||
InitializeComponent();
|
||||
// Tunnel: the TextBox handles Ctrl+V itself (text only), so an image paste has to be caught first.
|
||||
DescriptionBox.AddHandler(KeyDownEvent, OnDescriptionPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||
}
|
||||
|
||||
private void OnDescriptionPreviewKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.V || !e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Alt))
|
||||
return;
|
||||
|
||||
// The clipboard read is async, so swallow the key and replay the text paste when there is no image.
|
||||
e.Handled = true;
|
||||
_ = PasteAsync();
|
||||
}
|
||||
|
||||
/// <summary>Attaches a clipboard bitmap as a PNG file; anything else falls back to the normal text paste.</summary>
|
||||
private async System.Threading.Tasks.Task PasteAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bitmap = await ClipboardBitmap.TryGetAsync(TopLevel.GetTopLevel(this));
|
||||
if (bitmap is null || DataContext is not DetailsIslandViewModel vm)
|
||||
{
|
||||
DescriptionBox.Paste();
|
||||
return;
|
||||
}
|
||||
|
||||
using var png = new System.IO.MemoryStream();
|
||||
bitmap.Save(png);
|
||||
png.Position = 0;
|
||||
await vm.AddFilesAsync(new[] { (ClipboardImageTempStore.NewPngFileName(DateTime.UtcNow), (System.IO.Stream)png) });
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Paste is best-effort; a failed clipboard/encode must not surface as an unhandled UI exception.
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnCopyClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -27,6 +27,20 @@ public class ClipboardImageTempStoreTests : IDisposable
|
||||
Assert.Equal(".png", Path.GetExtension(a));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewPngFileName_IsUniquePasteNameWithoutDirectory()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 16, 10, 30, 0, 123, DateTimeKind.Utc);
|
||||
|
||||
var a = ClipboardImageTempStore.NewPngFileName(now);
|
||||
var b = ClipboardImageTempStore.NewPngFileName(now);
|
||||
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.StartsWith("paste-20260916-103000123-", a);
|
||||
Assert.EndsWith(".png", a);
|
||||
Assert.Equal(a, Path.GetFileName(a));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user