feat(details): attach clipboard image on Ctrl+V in the task description
Extracts the clipboard bitmap read into a shared ClipboardBitmap helper (also used by the terminal view); the description TextBox now routes an image paste through AddFilesAsync as a unique paste-*.png attachment. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
d8f78056c1
commit
393e59460d
@@ -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
|
/// <summary>Test seam for <see cref="NewPngPath(DateTime)"/> — takes the target dir explicitly
|
||||||
/// instead of the real app data root.</summary>
|
/// instead of the real app data root.</summary>
|
||||||
internal static string NewPngPath(string dir, DateTime utcNow) =>
|
internal static string NewPngPath(string dir, DateTime utcNow) => Path.Combine(dir, NewPngFileName(utcNow));
|
||||||
Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png");
|
|
||||||
|
/// <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
|
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
|
||||||
/// explicitly instead of the real app data root.</summary>
|
/// explicitly instead of the real app data root.</summary>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ public partial class InteractiveTerminalView : UserControl
|
|||||||
Avalonia.Media.Imaging.Bitmap? bitmap = null;
|
Avalonia.Media.Imaging.Bitmap? bitmap = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
|
bitmap = await ClipboardBitmap.TryGetAsync(TopLevel.GetTopLevel(view));
|
||||||
if (bitmap is null)
|
if (bitmap is null)
|
||||||
{
|
{
|
||||||
if (!await vm.PasteAsync()) vm.ReportSendFailed();
|
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 static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
|
||||||
|
|
||||||
private void OnDragOver(object? sender, DragEventArgs e)
|
private void OnDragOver(object? sender, DragEventArgs e)
|
||||||
|
|||||||
@@ -103,7 +103,8 @@
|
|||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<Panel IsVisible="{Binding IsDescriptionSection}">
|
<Panel IsVisible="{Binding IsDescriptionSection}">
|
||||||
<!-- Edit mode: raw TextBox -->
|
<!-- Edit mode: raw TextBox -->
|
||||||
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
|
<TextBox x:Name="DescriptionBox"
|
||||||
|
Text="{Binding EditableDescription, Mode=TwoWay}"
|
||||||
AcceptsReturn="True"
|
AcceptsReturn="True"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
MinHeight="80"
|
MinHeight="80"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Avalonia.Input;
|
|||||||
using Avalonia.Input.Platform;
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
||||||
@@ -12,6 +13,41 @@ public partial class DescriptionStepsCard : UserControl
|
|||||||
public DescriptionStepsCard()
|
public DescriptionStepsCard()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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)
|
private async void OnCopyClick(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ public class ClipboardImageTempStoreTests : IDisposable
|
|||||||
Assert.Equal(".png", Path.GetExtension(a));
|
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]
|
[Fact]
|
||||||
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
|
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user