Public Access
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>
This commit is contained in:
1 parent
2810f7c12b
commit
1908189100
16 files changed
+1557
-22
No files matched your search
@@ -71,6 +71,63 @@ internal sealed partial class FakeRenderer : IAsyncDisposable
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/// <summary>Sends a client frame, as the page would.</summary>
|
||||
/// <remarks>
|
||||
/// Raw rather than through a helper per opcode: the two this exists for carry a session id and either
|
||||
/// UTF-8 or nothing at all, and a method each would be three lines of ceremony around
|
||||
/// <see cref="TerminalFrame.Create"/>.
|
||||
/// </remarks>
|
||||
internal async Task SendAsync(
|
||||
TerminalClientOpcode opcode,
|
||||
uint sessionId,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken) =>
|
||||
await socket
|
||||
.SendAsync(
|
||||
TerminalFrame.Create((byte)opcode, sessionId, payload.Span),
|
||||
WebSocketMessageType.Binary,
|
||||
endOfMessage: true,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <summary>Reads frames until one carries that opcode, and answers with it.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Skipping rather than expecting the next frame to be the interesting one, because a live session's
|
||||
/// socket is not quiet: the shell's opening frame, its activation and whatever the remote said are all
|
||||
/// on the way, and which of them have arrived by the time a test looks is a scheduling accident.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Bounded only by the caller's token, which in this suite is the test's own — so a frame that never
|
||||
/// comes fails the test on its timeout rather than hanging the run.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<byte[]> WaitForAsync(
|
||||
TerminalServerOpcode opcode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var result = await socket
|
||||
.ReceiveAsync(buffer.AsMemory(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The host closed the socket before sending a {opcode} frame.");
|
||||
}
|
||||
|
||||
if (TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var read, out _, out var payload)
|
||||
&& read == (byte)opcode)
|
||||
{
|
||||
return payload.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -33,6 +33,15 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// <summary>Everything this shell has copied, newest last.</summary>
|
||||
private readonly List<string> clipboard = [];
|
||||
|
||||
/// <summary>
|
||||
/// What the machine's clipboard holds, as far as this shell can read it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null rather than empty by default, which is what a clipboard holding something that is not text
|
||||
/// answers. Set by the one test that needs the shell to read one — the terminal's right-click paste.
|
||||
/// </remarks>
|
||||
private string? machineClipboard;
|
||||
|
||||
/// <remarks>
|
||||
/// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
|
||||
/// state machine, not about how expensive the passphrase is to attack.
|
||||
@@ -164,6 +173,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
|
||||
// And the other direction, which only the terminal's right-click paste uses. A closure over the
|
||||
// field rather than a value captured now, because a test sets it after the shell is built.
|
||||
readClipboard: () => Task.FromResult(machineClipboard),
|
||||
|
||||
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
|
||||
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
|
||||
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
|
||||
@@ -294,6 +307,90 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The terminal's right-click, which is a copy or a paste ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The gesture is heard by the page and nowhere else — the selection is drawn into a canvas, and a
|
||||
/// focused terminal leaves the host's window no input events at all — so the shell's part is to be the
|
||||
/// clipboard. This is that wiring end to end: a real frame on the real socket, through the workspace, to
|
||||
/// the delegate a head supplies.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Sent with no session open at all, deliberately. A copy is not about a terminal, and the frame is
|
||||
/// dispatched before the session lookup precisely so that copying the last thing a dead tab said still
|
||||
/// works — which is when somebody is most likely to want it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARightClickCopy_ReachesTheMachinesClipboard()
|
||||
{
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await renderer.SendAsync(
|
||||
TerminalClientOpcode.ClipboardCopy,
|
||||
sessionId: 0,
|
||||
System.Text.Encoding.UTF8.GetBytes("error: nothing was written"),
|
||||
Token);
|
||||
|
||||
await EventuallyAsync(
|
||||
() => clipboard.Contains("error: nothing was written", StringComparer.Ordinal),
|
||||
"the copy frame should have reached the clipboard delegate");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other direction, and the half the page cannot do for itself: reading a clipboard from a WebView
|
||||
/// needs the <c>clipboard-read</c> permission, which an embedded WebView answers with a prompt. So the
|
||||
/// page asks, the shell reads, and the answer goes back down the same socket addressed to the pane that
|
||||
/// asked — which is what this follows all the way round.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARightClickPaste_SendsTheMachinesClipboardBackToThePaneThatAsked()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var sessionId = shell.SelectedTab.ShouldNotBeNull().SessionId;
|
||||
|
||||
machineClipboard = "sudo systemctl restart sshd";
|
||||
|
||||
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
|
||||
|
||||
var payload = await renderer.WaitForAsync(TerminalServerOpcode.Clipboard, Token);
|
||||
|
||||
System.Text.Encoding.UTF8.GetString(payload).ShouldBe("sudo systemctl restart sshd");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty clipboard is answered with nothing rather than with an empty paste, which the page would
|
||||
/// bracket and hand the remote as a keystroke nobody made. Proven by the frame that <em>does</em> come
|
||||
/// after it: the receive loop is sequential, so a clipboard frame for the second request arriving means
|
||||
/// the first was seen and passed over.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARightClickPaste_SendsNothingWhenTheClipboardHasNoText()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var sessionId = shell.SelectedTab.ShouldNotBeNull().SessionId;
|
||||
|
||||
machineClipboard = null;
|
||||
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
|
||||
|
||||
machineClipboard = "uptime";
|
||||
await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token);
|
||||
|
||||
var payload = await renderer.WaitForAsync(TerminalServerOpcode.Clipboard, Token);
|
||||
|
||||
System.Text.Encoding.UTF8.GetString(payload)
|
||||
.ShouldBe("uptime", "the empty clipboard sent no frame of its own");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AFreshMachine_AsksForAServer()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user