149 lines
5.8 KiB
C#
149 lines
5.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Platform.Storage;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels;
|
|
|
|
namespace ClaudeDo.Ui.Views;
|
|
|
|
public partial class InteractiveTerminalView : UserControl
|
|
{
|
|
public InteractiveTerminalView()
|
|
{
|
|
InitializeComponent();
|
|
TerminalHost.Loaded += OnTerminalHostLoaded;
|
|
DataContextChanged += OnDataContextChanged;
|
|
|
|
// Tunnel: TerminalControl handles KeyDown itself and forwards Ctrl+V to the child as a raw
|
|
// control byte, where nothing acts on it — paste has to be intercepted before it gets there.
|
|
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
|
AddHandler(DragDrop.DragOverEvent, OnDragOver);
|
|
AddHandler(DragDrop.DropEvent, OnDrop);
|
|
}
|
|
|
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
|
{
|
|
if (DataContext is InteractiveTerminalViewModel vm && TerminalHost.IsLoaded)
|
|
vm.AttachControl(TerminalHost);
|
|
}
|
|
|
|
private void OnTerminalHostLoaded(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (DataContext is InteractiveTerminalViewModel vm)
|
|
vm.AttachControl(TerminalHost);
|
|
}
|
|
|
|
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
|
|
{
|
|
// AltGr arrives as Ctrl+Alt; the library would turn it into a control sequence (and mark it handled).
|
|
if (IsAltGrText(e.KeyModifiers, e.KeySymbol))
|
|
{
|
|
if (DataContext is not InteractiveTerminalViewModel altGrVm) return;
|
|
e.Handled = true;
|
|
_ = SendTextAndReportAsync(altGrVm, e.KeySymbol!);
|
|
return;
|
|
}
|
|
|
|
if (e.Key != Key.V || !e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Alt))
|
|
return;
|
|
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
|
|
|
e.Handled = true;
|
|
_ = PasteAndReportAsync(this, 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)
|
|
{
|
|
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();
|
|
}
|
|
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 void OnDragOver(object? sender, DragEventArgs e)
|
|
{
|
|
e.DragEffects = IsFilesDrop(e) ? DragDropEffects.Copy : DragDropEffects.None;
|
|
e.Handled = true;
|
|
}
|
|
|
|
// Dropping files types their paths into the session's composer (no Enter) — the common case is
|
|
// "look at this file", not "run something".
|
|
private void OnDrop(object? sender, DragEventArgs e)
|
|
{
|
|
if (DataContext is not InteractiveTerminalViewModel vm || !IsFilesDrop(e)) return;
|
|
e.Handled = true;
|
|
|
|
var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath()));
|
|
if (text.Length > 0) _ = SendTextAndReportAsync(vm, text);
|
|
}
|
|
|
|
private static async System.Threading.Tasks.Task SendTextAndReportAsync(InteractiveTerminalViewModel vm, string text)
|
|
{
|
|
if (!await vm.SendTextAsync(text))
|
|
vm.ReportSendFailed();
|
|
}
|
|
|
|
/// <summary>True for an AltGr keystroke (Windows reports it as Ctrl+Alt) whose
|
|
/// <paramref name="keySymbol"/> is already a single printable character, e.g. "@" or "\".</summary>
|
|
internal static bool IsAltGrText(KeyModifiers modifiers, string? keySymbol) =>
|
|
modifiers.HasFlag(KeyModifiers.Control) && modifiers.HasFlag(KeyModifiers.Alt)
|
|
&& keySymbol is { Length: 1 } && !char.IsControl(keySymbol[0]);
|
|
|
|
/// <summary>Joins dropped file paths into one insertable chunk: quoted when they contain a
|
|
/// space, space-separated, with a trailing space so the user can keep typing.</summary>
|
|
internal static string FormatDroppedPaths(IEnumerable<string?>? paths)
|
|
{
|
|
var usable = (paths ?? Enumerable.Empty<string?>())
|
|
.Where(p => !string.IsNullOrWhiteSpace(p))
|
|
.Select(p => p!.Contains(' ') ? $"\"{p}\"" : p)
|
|
.ToList();
|
|
return usable.Count == 0 ? string.Empty : string.Join(" ", usable) + " ";
|
|
}
|
|
}
|