From 393e59460d6592c402bb5b1e12d5b2cad99578e1 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 2 Oct 2026 10:31:47 +0200 Subject: [PATCH] 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 --- src/ClaudeDo.Ui/Services/ClipboardBitmap.cs | 26 ++++++++++++++ .../Services/ClipboardImageTempStore.cs | 7 ++-- .../Views/InteractiveTerminalView.axaml.cs | 16 +-------- .../Islands/Detail/DescriptionStepsCard.axaml | 3 +- .../Detail/DescriptionStepsCard.axaml.cs | 36 +++++++++++++++++++ .../Services/ClipboardImageTempStoreTests.cs | 14 ++++++++ 6 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 src/ClaudeDo.Ui/Services/ClipboardBitmap.cs diff --git a/src/ClaudeDo.Ui/Services/ClipboardBitmap.cs b/src/ClaudeDo.Ui/Services/ClipboardBitmap.cs new file mode 100644 index 00000000..c1be4a9d --- /dev/null +++ b/src/ClaudeDo.Ui/Services/ClipboardBitmap.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media.Imaging; + +namespace ClaudeDo.Ui.Services; + +/// Reads a bitmap off the clipboard; shared by the terminal view and the description editor. +public static class ClipboardBitmap +{ + /// The clipboard bitmap (caller disposes), or null if there is none or the read failed. + public static async Task 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; + } + } +} diff --git a/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs b/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs index 20479501..33e6b01d 100644 --- a/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs +++ b/src/ClaudeDo.Ui/Services/ClipboardImageTempStore.cs @@ -22,8 +22,11 @@ public static class ClipboardImageTempStore /// Test seam for — takes the target dir explicitly /// instead of the real app data root. - 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)); + + /// A fresh, collision-free paste-….png file name (also used as an attachment name). + public static string NewPngFileName(DateTime utcNow) => + $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png"; /// Test seam for — takes the target dir /// explicitly instead of the real app data root. diff --git a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs index b0c2f2a7..2a358ab3 100644 --- a/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml.cs @@ -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 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) diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml index 7af59769..ee089612 100644 --- a/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml @@ -103,7 +103,8 @@ - Attaches a clipboard bitmap as a PNG file; anything else falls back to the normal text paste. + 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) diff --git a/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs b/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs index f335ccdc..b1263e7f 100644 --- a/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs +++ b/tests/ClaudeDo.Ui.Tests/Services/ClipboardImageTempStoreTests.cs @@ -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() {