feat(ui): Bilder aus der Zwischenablage ins interaktive Terminal einfuegen
Ctrl+V mit einem Bild in der Zwischenablage schreibt es als temporaeres PNG unter ~/.claudeDo/tmp/ und fuegt den Pfad in den Composer ein, genau wie ein Datei-Drop -- Claude liest die Datei selbst. Text-Paste laeuft unveraendert ueber den bestehenden bracketed-paste-Pfad. Avalonia 12.0.4 liest Clipboard-Bitmaps bereits selbst (IClipboard. TryGetDataAsync + AsyncDataTransferExtensions.TryGetBitmapAsync), kein Win32-P/Invoke noetig. Alte Tmp-Dateien (>24h) werden beim App-Start aufgeraeumt (ClaudeDo.App/Program.cs), keine eigene Aufraeum-Infrastruktur.
This commit is contained in:
@@ -42,6 +42,8 @@ sealed class Program
|
|||||||
ClaudeDoDbContext.MigrateAndConfigure(db);
|
ClaudeDoDbContext.MigrateAndConfigure(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ClipboardImageTempStore.DeleteOlderThan(TimeSpan.FromHours(24), DateTime.UtcNow);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ConfigureAppBuilder(AppBuilder.Configure(() => new App(services)))
|
ConfigureAppBuilder(AppBuilder.Configure(() => new App(services)))
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Temp PNGs written for clipboard-image paste into the interactive terminal (see
|
||||||
|
/// <see cref="ClaudeDo.Ui.Views.InteractiveTerminalView"/>) — dropped into the composer as a path,
|
||||||
|
/// the same way a file drop is, then read by Claude like any other file.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClipboardImageTempStore
|
||||||
|
{
|
||||||
|
public static string Directory => Path.Combine(Paths.AppDataRoot(), "tmp");
|
||||||
|
|
||||||
|
/// <summary>A fresh, collision-free path for one pasted image.</summary>
|
||||||
|
public static string NewPngPath(DateTime utcNow) => NewPngPath(Directory, utcNow);
|
||||||
|
|
||||||
|
/// <summary>Deletes files older than <paramref name="maxAge"/> under the tmp dir — called once at
|
||||||
|
/// app startup, not a running cleanup service.</summary>
|
||||||
|
public static void DeleteOlderThan(TimeSpan maxAge, DateTime utcNow) => DeleteOlderThan(Directory, maxAge, utcNow);
|
||||||
|
|
||||||
|
/// <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");
|
||||||
|
|
||||||
|
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
|
||||||
|
/// explicitly instead of the real app data root.</summary>
|
||||||
|
internal static void DeleteOlderThan(string dir, TimeSpan maxAge, DateTime utcNow)
|
||||||
|
{
|
||||||
|
if (!System.IO.Directory.Exists(dir)) return;
|
||||||
|
|
||||||
|
foreach (var file in System.IO.Directory.EnumerateFiles(dir))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (utcNow - File.GetLastWriteTimeUtc(file) > maxAge)
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
catch (IOException) { /* best effort */ }
|
||||||
|
catch (UnauthorizedAccessException) { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views;
|
namespace ClaudeDo.Ui.Views;
|
||||||
@@ -43,14 +45,55 @@ public partial class InteractiveTerminalView : UserControl
|
|||||||
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
||||||
|
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
_ = PasteAndReportAsync(vm);
|
_ = PasteAndReportAsync(this, vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
|
// A clipboard image (e.g. a screenshot) can't be typed into the child process, so it's written
|
||||||
|
// to a temp PNG and its path is inserted the same way a dropped file is — Claude reads the file
|
||||||
|
// itself. Plain-text clipboard content keeps going through the existing bracketed-paste flow.
|
||||||
|
// Once a bitmap is confirmed present, any failure (dir/file write, send) is a real paste
|
||||||
|
// failure — never silently fall back to a text paste of the same clipboard.
|
||||||
|
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalView view, InteractiveTerminalViewModel vm)
|
||||||
{
|
{
|
||||||
if (!await vm.PasteAsync())
|
Avalonia.Media.Imaging.Bitmap? bitmap = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
|
||||||
|
if (bitmap is null)
|
||||||
|
{
|
||||||
|
if (!await vm.PasteAsync()) vm.ReportSendFailed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = ClipboardImageTempStore.NewPngPath(DateTime.UtcNow);
|
||||||
|
Directory.CreateDirectory(ClipboardImageTempStore.Directory);
|
||||||
|
bitmap.Save(path);
|
||||||
|
if (!await vm.SendTextAsync(FormatDroppedPaths(new[] { path })))
|
||||||
vm.ReportSendFailed();
|
vm.ReportSendFailed();
|
||||||
}
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
vm.ReportSendFailed();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
bitmap?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.Services;
|
||||||
|
|
||||||
|
public class ClipboardImageTempStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir;
|
||||||
|
|
||||||
|
public ClipboardImageTempStoreTests()
|
||||||
|
{
|
||||||
|
_dir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { try { Directory.Delete(_dir, true); } catch { } }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewPngPath_IsUniqueAndUnderTheGivenDir()
|
||||||
|
{
|
||||||
|
var now = new DateTime(2026, 9, 16, 10, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
var a = ClipboardImageTempStore.NewPngPath(_dir, now);
|
||||||
|
var b = ClipboardImageTempStore.NewPngPath(_dir, now);
|
||||||
|
|
||||||
|
Assert.NotEqual(a, b);
|
||||||
|
Assert.Equal(_dir, Path.GetDirectoryName(a));
|
||||||
|
Assert.Equal(".png", Path.GetExtension(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var oldFile = Path.Combine(_dir, "old.png");
|
||||||
|
var freshFile = Path.Combine(_dir, "fresh.png");
|
||||||
|
File.WriteAllText(oldFile, "x");
|
||||||
|
File.WriteAllText(freshFile, "x");
|
||||||
|
File.SetLastWriteTimeUtc(oldFile, now - TimeSpan.FromHours(25));
|
||||||
|
File.SetLastWriteTimeUtc(freshFile, now - TimeSpan.FromHours(1));
|
||||||
|
|
||||||
|
ClipboardImageTempStore.DeleteOlderThan(_dir, TimeSpan.FromHours(24), now);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(oldFile));
|
||||||
|
Assert.True(File.Exists(freshFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteOlderThan_NonExistentDir_DoesNotThrow()
|
||||||
|
{
|
||||||
|
ClipboardImageTempStore.DeleteOlderThan(Path.Combine(_dir, "missing"), TimeSpan.FromHours(24), DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user