fix(ui): report failed paste/drop in the interactive terminal
PasteAsync silently swallowed every exception and returned void, so a package update renaming Iciclecreek's private write seam would break Ctrl+V/drop with zero user-visible signal. PasteAsync/SendTextAsync now return bool (mirroring SendLineAsync), and the view checks the result, routing a failure through InteractiveTerminalViewModel.ErrorReported -> ConPtyPaneViewModel.ErrorReported -> the existing footer error strip.
This commit is contained in:
@@ -384,6 +384,7 @@
|
||||
"conptyStarting": "Sitzung wird gestartet…",
|
||||
"conptyExited": "Sitzung beendet (Code {0}).",
|
||||
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
|
||||
"terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet werden.",
|
||||
"mergeHelperTitle": "Merge-Helfer",
|
||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||
|
||||
@@ -384,6 +384,7 @@
|
||||
"conptyStarting": "Starting session…",
|
||||
"conptyExited": "Session ended (code {0}).",
|
||||
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
|
||||
"terminalSendFailed": "Couldn't send that to the terminal session.",
|
||||
"mergeHelperTitle": "Merge Helper",
|
||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||
"mergeHelperTaskTitle": "List handler: {0}",
|
||||
|
||||
@@ -162,15 +162,19 @@ public sealed class PtyTerminalSession : IDisposable
|
||||
/// writing the clipboard text raw, because that is what honours bracketed-paste mode — without
|
||||
/// it a multi-line paste is read line by line and submits on the first newline.
|
||||
/// </summary>
|
||||
public async Task PasteAsync()
|
||||
public async Task<bool> PasteAsync()
|
||||
{
|
||||
if (_control is not { } control || !IsRunning) return;
|
||||
if (_control is not { } control || !IsRunning) return false;
|
||||
try
|
||||
{
|
||||
if (s_terminalViewField?.GetValue(control) is TerminalView view)
|
||||
await view.PasteAsync();
|
||||
if (s_terminalViewField?.GetValue(control) is not TerminalView view) return false;
|
||||
await view.PasteAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception) { /* best-effort, same as SendLineAsync */ }
|
||||
}
|
||||
|
||||
// Long enough that the two writes can't be read as one chunk by the child's stdin reader.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using Iciclecreek.Terminal;
|
||||
|
||||
@@ -111,6 +112,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised when a user-initiated paste or drop couldn't reach the session (write seam
|
||||
/// gone, no running process) — mirrors the island ViewModels' ErrorReported, forwarded by
|
||||
/// <see cref="ClaudeDo.Ui.ViewModels.MissionControl.ConPtyPaneViewModel"/> into the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
/// <summary>Called by the view after a failed <see cref="PasteAsync"/>/<see cref="SendTextAsync"/>
|
||||
/// so the failure surfaces through <see cref="ErrorReported"/> instead of vanishing silently.</summary>
|
||||
public void ReportSendFailed() => ErrorReported?.Invoke(Loc.T("missionControl.terminalSendFailed"));
|
||||
|
||||
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
|
||||
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
|
||||
/// the worker turns it into a usage-limit auto-restart for list-handler sessions
|
||||
@@ -156,8 +166,9 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
public Task<bool> SendTextAsync(string text) => _session.SendTextAsync(text);
|
||||
|
||||
/// <summary>Pastes the clipboard into the session (Ctrl+V) — see
|
||||
/// <see cref="PtyTerminalSession.PasteAsync"/>.</summary>
|
||||
public Task PasteAsync() => _session.PasteAsync();
|
||||
/// <see cref="PtyTerminalSession.PasteAsync"/>. False when there is no running process to
|
||||
/// paste into (or the write seam is gone).</summary>
|
||||
public Task<bool> PasteAsync() => _session.PasteAsync();
|
||||
|
||||
public void Kill() => _session.Kill();
|
||||
|
||||
|
||||
@@ -89,14 +89,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
|
||||
terminal.ErrorReported += OnTerminalErrorReported;
|
||||
}
|
||||
|
||||
private void Unsubscribe(InteractiveTerminalViewModel terminal)
|
||||
{
|
||||
terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
|
||||
terminal.ErrorReported -= OnTerminalErrorReported;
|
||||
}
|
||||
|
||||
private void OnTerminalErrorReported(string message) => ErrorReported?.Invoke(message);
|
||||
|
||||
private void OnTerminalExitedAfterStartup(int exitCode)
|
||||
{
|
||||
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
|
||||
|
||||
@@ -43,7 +43,13 @@ public partial class InteractiveTerminalView : UserControl
|
||||
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
||||
|
||||
e.Handled = true;
|
||||
_ = vm.PasteAsync();
|
||||
_ = PasteAndReportAsync(vm);
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
|
||||
{
|
||||
if (!await vm.PasteAsync())
|
||||
vm.ReportSendFailed();
|
||||
}
|
||||
|
||||
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
|
||||
@@ -62,7 +68,13 @@ public partial class InteractiveTerminalView : UserControl
|
||||
e.Handled = true;
|
||||
|
||||
var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath()));
|
||||
if (text.Length > 0) _ = vm.SendTextAsync(text);
|
||||
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>Joins dropped file paths into one insertable chunk: quoted when they contain a
|
||||
|
||||
@@ -35,4 +35,14 @@ public class PtyTerminalSessionTests
|
||||
|
||||
Assert.Equal(0, gate.CurrentCount); // acquired
|
||||
}
|
||||
|
||||
// Fix: PasteAsync used to be void and swallow every failure. Not-started is the cheap case to
|
||||
// cover without a real TerminalControl/ConPTY spawn.
|
||||
[Fact]
|
||||
public async Task PasteAsync_ReturnsFalse_WhenNotStarted()
|
||||
{
|
||||
using var session = new PtyTerminalSession();
|
||||
|
||||
Assert.False(await session.PasteAsync());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,35 @@ public class ConPtyPaneViewModelTests
|
||||
Assert.True(raised);
|
||||
}
|
||||
|
||||
// ── Terminal.ErrorReported forwarding (fix: a failed paste/drop had no path to the footer
|
||||
// error strip) ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TerminalErrorReported_Forwards_ToPaneErrorReported()
|
||||
{
|
||||
using var pane = NewTaskPane(NeverCompletes);
|
||||
string? error = null;
|
||||
pane.ErrorReported += msg => error = msg;
|
||||
|
||||
pane.Terminal.ReportSendFailed();
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminalErrorReported_StillWiredAfterRetry()
|
||||
{
|
||||
using var pane = NewTaskPane(() => Failing());
|
||||
pane.Start();
|
||||
pane.RetryCommand.Execute(null);
|
||||
string? error = null;
|
||||
pane.ErrorReported += msg => error = msg;
|
||||
|
||||
pane.Terminal.ReportSendFailed();
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
|
||||
|
||||
// Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch
|
||||
|
||||
+31
@@ -124,4 +124,35 @@ public class InteractiveTerminalViewModelTests
|
||||
terminal.IsRunning = true; // records LaunchedAt via the first clock call
|
||||
return terminal;
|
||||
}
|
||||
|
||||
// ── Paste/drop failure reporting (fix: PasteAsync used to swallow every exception and return
|
||||
// void, so a failed Ctrl+V or file drop gave the user no signal at all) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PasteAsync_ReturnsFalse_WhenSessionNotRunning()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel();
|
||||
|
||||
Assert.False(await terminal.PasteAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTextAsync_ReturnsFalse_WhenSessionNotRunning()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel();
|
||||
|
||||
Assert.False(await terminal.SendTextAsync("dropped/path"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportSendFailed_RaisesErrorReported()
|
||||
{
|
||||
using var terminal = new InteractiveTerminalViewModel();
|
||||
string? error = null;
|
||||
terminal.ErrorReported += msg => error = msg;
|
||||
|
||||
terminal.ReportSendFailed();
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user