Files
DodoSSH/tests/DodoSSH.Client.Terminal.Tests/RendererClipboardTests.cs
T
laurensandClaude Opus 5 1908189100
ci / build and test (push) Successful in 5m43s
ci / android head (push) Successful in 4m4s
ci / desktop nightly (push) Successful in 1m28s
ci / api image (push) Successful in 26s
Make the terminal's right button copy and paste, and never open a menu
Selecting output and right-clicking it opened the WebView's own context menu with
Copy on it, which is correct behaviour for a web page and wrong for a terminal.
What a terminal's right button has meant since the Windows console got QuickEdit,
and since PuTTY shipped, is this: with something selected it copies and drops the
selection, with nothing selected it pastes. No menu, no second click.

The gesture can only live in the page. The selection is drawn into a canvas the
host never sees, and once a terminal has focus the host's window receives no input
events at all — the same reason the Ctrl+plus chords are handled there.

◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY.

navigator.clipboard.readText() is gated behind the clipboard-read permission in
every Chromium, and an embedded WebView with no PermissionRequested handler
answers a permission request by putting a prompt on the screen. Trading a context
menu for a permission dialog is not this change. So the page asks and the host
answers, exactly as requestFontSizeStep does.

Writing does not go the other way either, although navigator.clipboard.writeText
would have worked from the page on Windows: a clipboard that is the host's in one
direction and the page's in the other is two mechanisms to keep working across
WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate
since the keychain grew its COPY buttons, so each App.axaml.cs grows a
ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it.

Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard.

◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING.

Paste carries a snippet off the sidebar, and the page deliberately *types* a
single-line one rather than bracketing it, because bash marks a bracketed paste as
an active region and a snippet should read as though it had been typed. A clipboard
paste is the case those markers are for — readline uses them to decide it has been
pasted into — so Clipboard is always bracketed and never followed by an Enter. One
opcode for both would have needed a flag meaning "which of the two kinds of paste
is this", on a payload whose existing flag byte means something else.

Both client frames are dispatched before the session lookup, which is what keeps
the gesture working in a pane whose shell has just ended: squinting at the message
a remote left behind is exactly when somebody copies the line. Dispatch was past
MA0051's length once it grew a second such branch, so the frames no pump needs to
see moved into Announce() and the font-size chord went with them.

◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.

preventDefault alone suppresses the menu and still lets xterm's own contextmenu
handler run, which moves its helper textarea under the pointer at z-index 1000 and
leaves the selection sitting in it, selected. Both of those exist to serve the
native menu. Taking the event in the capture phase on the pane and stopping it
there is what keeps the gesture wholly the page's.

rightClickSelectsWord is now false, which on macOS is a change of default rather
than a restatement of it: xterm sets it true there alone, so a right-click would
have selected the word under the pointer and copied that instead of pasting —
wrong on one platform only, which is the worst way for it to be wrong.

The renderer harness grows the pane it has been inviting since
RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec,
because both directions run through the page's own TextEncoder and TextDecoder and
a Latin-1 stub would pass a test with an em dash in the selection. Its warning
about declaring names terminal.js declares now covers the half that does not
SyntaxError — a harness frame() was silently replaced by the page's frame() and
failed six tests on a type error a long way from the cause.

Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu
event reaches a capture listener, that no native menu is drawn once it is
prevented, and that xterm's real paste() brackets. 11.13 also records what is
unverified — whether Android's WebView delivers a pointer's secondary click as a
contextmenu event at all. ClipboardReader is wired on that head regardless,
because the alternative is a head where the paste half silently does nothing and
nobody finds out; that head is not compiled here either, this machine having no
android workload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:46:36 +02:00

277 lines
10 KiB
C#

namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// The renderer page's right button: a copy, a paste, and never a menu.
/// </summary>
/// <remarks>
/// <para>
/// The gesture every terminal has had since the Windows console got QuickEdit — select and right-click to
/// copy, right-click with nothing selected to paste. It lives in the page because that is the only place it
/// can: the selection is drawn into a canvas the host never sees, and once a terminal has focus the host's
/// window receives no input events at all.
/// </para>
/// <para>
/// What is asserted here is which frame the page sends, addressed to which session, and which of xterm's two
/// insertion routes it takes on the way back. What is deliberately <em>not</em> asserted is anything about
/// xterm itself — whether <c>paste()</c> really brackets, whether a selection really loses its right
/// padding — because the fake terminal in the harness is a stub with a memory and not an emulator. That
/// boundary is where <c>docs/manual-checks.md</c> 1.11 picks up.
/// </para>
/// </remarks>
public sealed class RendererClipboardTests
{
/// <summary>The session every single-pane test here opens.</summary>
private const uint SessionId = 7;
[Fact]
public void ARightClickOverASelection_CopiesIt()
{
var page = Attached();
page.Do("select(7, 'error: nothing was written')");
page.Do("rightClick(7)");
page.Sent.ShouldContain("5/7/error: nothing was written");
}
/// <remarks>
/// The console's behaviour, and load-bearing rather than cosmetic. Dropping the selection is the only
/// acknowledgement of the copy there is — nothing else on screen moves — and it is what makes the next
/// right-click a paste rather than a second copy of something forgotten about.
/// </remarks>
[Fact]
public void ARightClickOverASelection_DropsTheSelectionAfterCopyingIt()
{
var page = Attached();
page.Do("select(7, 'systemctl status sshd')");
page.Do("rightClick(7)");
page.Selection(SessionId).ShouldBeEmpty();
}
[Fact]
public void ARightClickWithNothingSelected_AsksTheHostForTheClipboard()
{
var page = Attached();
page.Do("rightClick(7)");
// No payload: the page has nothing to send, which is the whole reason it has to ask.
page.Sent.ShouldContain("6/7/");
}
/// <remarks>
/// The composite property, and the one a user would describe: copy, then paste. It passes only if the
/// copy dropped the selection — a page that left it standing sends a second copy frame here and never a
/// paste request, which is the failure that reads as "paste is broken".
/// </remarks>
[Fact]
public void ASecondRightClick_PastesBecauseTheFirstOneCopied()
{
var page = Attached();
page.Do("select(7, 'df -h')");
page.Do("rightClick(7)");
page.Do("rightClick(7)");
page.Sent.ShouldContain("5/7/df -h");
page.Sent.ShouldContain("6/7/");
}
/// <remarks>
/// <para>
/// <c>preventDefault</c> is the WebView's own context menu, which is what this whole change is about.
/// </para>
/// <para>
/// <c>stopPropagation</c> is xterm's, and it is the half that is easy to leave out. xterm registers its
/// own <c>contextmenu</c> handler on the element inside the pane, which parks a 20-pixel textarea under
/// the pointer at z-index 1000 and leaves the selection sitting in it, selected — both of which exist to
/// serve the native menu and are litter without one. The page takes the event in the capture phase to
/// get ahead of it; a listener that forgot the third argument to <c>addEventListener</c> would still
/// suppress the menu and would fail here.
/// </para>
/// </remarks>
[Fact]
public void ARightClick_TakesTheEventFromTheBrowserAndFromXterm()
{
var page = Attached();
page.Do("rightClick(7)");
page.Prevented.ShouldBe(1);
page.Stopped.ShouldBe(1);
}
/// <remarks>
/// The panes cover the page — they are positioned at inset 0 — so the only right-click that misses one is
/// on the status banner, which is drawn over the bottom of the pane while it has something to say. That
/// used to be the one place in a terminal window that still opened a browser menu.
/// </remarks>
[Fact]
public void ARightClickThatMissesEveryPane_StillOpensNoMenu()
{
var page = Attached();
page.Do("rightClickOffAPane()");
page.Prevented.ShouldBe(1);
// And nothing is stopped, because there is nothing below the document to stop it from — nor any
// frame sent, because a click that is on no pane is a click about no session.
page.Stopped.ShouldBe(0);
}
/// <remarks>
/// A right-click over a pane with a selection sends one frame and not two: the branch is exclusive, and a
/// page that asked for the clipboard as well would paste over the selection it had just copied.
/// </remarks>
[Fact]
public void ARightClickOverASelection_DoesNotAlsoAskForTheClipboard()
{
var page = Attached();
page.Do("select(7, 'uname -a')");
page.Do("rightClick(7)");
page.Sent.ShouldNotContain("6/7/");
}
/// <remarks>
/// Two panes, because the session id on a clipboard frame is the whole of what puts the text where the
/// pointer was. They agree with the host's own idea of the active tab in every arrangement that exists
/// today; the page is what knows, so this is what proves the page says.
/// </remarks>
[Fact]
public void ARightClick_IsAddressedToThePaneItHappenedIn()
{
var page = Attached();
page.Do("openSession(9)");
page.Do("select(7, 'the first pane'); select(9, 'the second pane')");
page.Do("rightClick(9)");
page.Sent.ShouldContain("5/9/the second pane");
page.Sent.ShouldNotContain("5/7/the first pane");
}
/// <remarks>
/// The other half of the round trip. <c>paste()</c> rather than <c>input()</c>, because this text came
/// off the system clipboard and that is the case bracketed paste exists for: a shell that receives a
/// multi-line command inside the markers treats every newline as text rather than as "run this".
/// </remarks>
[Fact]
public void TheClipboardComingBack_IsPastedRatherThanTyped()
{
var page = Attached();
page.Do("deliverClipboard(7, 'sudo systemctl restart sshd')");
page.Pasted(SessionId).ShouldBe("sudo systemctl restart sshd");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <remarks>
/// No Enter, ever. A clipboard paste inserts and stops; pressing Enter for somebody is what the sidebar's
/// RUN does, and that is a thing they asked for by name.
/// </remarks>
[Fact]
public void TheClipboardComingBack_DoesNotPressEnter()
{
var page = Attached();
page.Do("deliverClipboard(7, 'rm -rf /var/tmp/build')");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <summary>The caret follows the paste, so the next keystroke lands where the text did.</summary>
[Fact]
public void TheClipboardComingBack_LeavesTheCaretInThatPane()
{
var page = Attached();
var before = page.Focuses(SessionId);
page.Do("deliverClipboard(7, 'whoami')");
page.Focuses(SessionId).ShouldBe(before + 1);
}
/// <remarks>
/// Multi-byte, because both directions run through the page's own <c>TextEncoder</c> and
/// <c>TextDecoder</c> and a character that straddles nothing is not a test of either. The harness's codec
/// is a real UTF-8 one for this reason; see its remark.
/// </remarks>
[Fact]
public void TheTextSurvivesTheRoundTrip_WhenItIsNotAscii()
{
var page = Attached();
page.Do("select(7, 'échec — 90° · λ')");
page.Do("rightClick(7)");
page.Do("deliverClipboard(7, 'échec — 90° · λ')");
page.Sent.ShouldContain("5/7/échec — 90° · λ");
page.Pasted(SessionId).ShouldBe("échec — 90° · λ");
}
/// <remarks>
/// The distinction the new opcode exists to keep, asserted from the other side: a snippet off the sidebar
/// is still <em>typed</em> when it is a single line, because bash marks a bracketed paste as an active
/// region and a snippet should read as though it had been typed. Sharing one opcode with the clipboard
/// would have meant choosing one of these two behaviours for both.
/// </remarks>
[Fact]
public void ASingleLineSnippet_IsStillTypedRatherThanPasted()
{
var page = Attached();
page.Do("deliverSnippet(7, 'tail -f /var/log/syslog', false)");
page.Typed(SessionId).ShouldBe("tail -f /var/log/syslog");
page.Pasted(SessionId).ShouldBeEmpty();
}
/// <inheritdoc cref="ASingleLineSnippet_IsStillTypedRatherThanPasted" />
[Fact]
public void AMultiLineSnippet_IsStillPastedRatherThanTyped()
{
var page = Attached();
page.Do("deliverSnippet(7, 'cd /srv\\nmake\\nmake install', false)");
page.Pasted(SessionId).ShouldBe("cd /srv\nmake\nmake install");
page.Typed(SessionId).ShouldBeEmpty();
}
/// <remarks>
/// And the Enter goes through the typed route even for a multi-line snippet, deliberately outside the
/// bracketing: a <c>\r</c> inside the wrapper arrives at the shell as a literal carriage return and
/// nothing runs.
/// </remarks>
[Fact]
public void ASnippetWithRun_PressesEnterOutsideTheBrackets()
{
var page = Attached();
page.Do("deliverSnippet(7, 'cd /srv\\nmake', true)");
page.Pasted(SessionId).ShouldBe("cd /srv\nmake");
page.Typed(SessionId).ShouldBe("\r");
}
/// <summary>A page with an open socket and one pane, which is where every test here starts.</summary>
private static RendererPage Attached()
{
var page = RendererPage.Load();
// The socket has to be open before anything is sent: send() drops a frame when it is not, which
// would make every assertion here pass or fail on the transport rather than on the gesture.
page.Do("accept(0)");
page.Do("openSession(7)");
return page;
}
}