feat(ui): Einfuegen und Datei-Drop im interaktiven Terminal

Ctrl+V wurde von TerminalControl als rohes Control-Byte an das Kind
weitergegeben und tat nichts; jetzt wird es im Tunnel abgefangen und geht
ueber TerminalView.PasteAsync, damit Bracketed Paste greift und ein
mehrzeiliger Paste nicht auf der ersten Zeile abschickt. Gedroppte Dateien
werden als Pfade in den Composer getippt, ohne Enter.
This commit is contained in:
mika kuns
2026-09-07 08:17:37 +02:00
parent 0db9fadee9
commit 23491d3b49
5 changed files with 122 additions and 1 deletions
@@ -4,7 +4,8 @@
xmlns:term="using:Iciclecreek.Terminal"
x:Class="ClaudeDo.Ui.Views.InteractiveTerminalView"
x:DataType="vm:InteractiveTerminalViewModel"
x:Name="Root">
x:Name="Root"
DragDrop.AllowDrop="True">
<!--
Process="" suppresses TerminalView.OnLoaded's built-in auto-launch (it otherwise spawns its
own cmd.exe/bash the moment this control loads). PtyTerminalSession sets Process/Args/
@@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
@@ -12,6 +16,12 @@ public partial class InteractiveTerminalView : UserControl
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)
@@ -25,4 +35,44 @@ public partial class InteractiveTerminalView : UserControl
if (DataContext is InteractiveTerminalViewModel vm)
vm.AttachControl(TerminalHost);
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
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;
_ = vm.PasteAsync();
}
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) _ = vm.SendTextAsync(text);
}
/// <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) + " ";
}
}