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
@@ -328,6 +328,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Func<string, Task>? copyToClipboard;
|
||||
|
||||
/// <remarks>
|
||||
/// The other direction, and it exists for one caller: a right-click in a terminal with nothing selected.
|
||||
/// The renderer cannot read a clipboard — <see cref="TerminalClientOpcode.ClipboardPaste"/> says why —
|
||||
/// so the gesture crosses the socket and is answered here. Null on a head that wires none, which is now
|
||||
/// no head at all: it stays nullable because this class has to be constructible without a visual, which
|
||||
/// is what every test that drives it does.
|
||||
/// </remarks>
|
||||
private readonly Func<Task<string?>>? readClipboard;
|
||||
|
||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
||||
private readonly Action<Action> post;
|
||||
|
||||
@@ -493,6 +502,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
||||
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||
/// </param>
|
||||
/// <param name="readClipboard">
|
||||
/// Reads this machine's clipboard, or null on a head that cannot. Answers the terminal's right-click
|
||||
/// paste and nothing else — see the field it is kept in.
|
||||
/// </param>
|
||||
/// <param name="openInEditor">
|
||||
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
|
||||
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
|
||||
@@ -527,6 +540,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
Func<Task<string?>>? readClipboard = null,
|
||||
string? deviceName = null,
|
||||
IUpdateChannel? updates = null,
|
||||
Action<Action>? post = null,
|
||||
@@ -545,6 +559,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
this.copyToClipboard = copyToClipboard;
|
||||
this.readClipboard = readClipboard;
|
||||
|
||||
// Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
|
||||
// blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
|
||||
@@ -575,6 +590,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
||||
this.workspace.ClipboardCopyRequested += OnClipboardCopyRequested;
|
||||
this.workspace.ClipboardPasteRequested += OnClipboardPasteRequested;
|
||||
this.workspace.RendererReattached += OnRendererReattached;
|
||||
|
||||
settings = new ClientSettingsStore(paths);
|
||||
@@ -726,6 +743,106 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
||||
|
||||
/// <summary>
|
||||
/// A right-click over a selection: the page has sent the text, and this puts it on the clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Marshalled for the reason <see cref="OnFontSizeStepRequested"/> is — this arrives on the data plane's
|
||||
/// receive loop — plus one of its own, which is the stronger: an Avalonia clipboard is reached through a
|
||||
/// <c>TopLevel</c>, and a <c>TopLevel</c> is only safe to touch from the thread that owns the window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Through <see cref="post"/> rather than <c>Dispatcher.UIThread</c> directly, unlike the handlers
|
||||
/// beside it: those predate the delegate and are the ones this shell's suite records as out of its
|
||||
/// reach. There is no reason to add another.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnClipboardCopyRequested(object? sender, TerminalClipboardCopyEventArgs e) =>
|
||||
post(() => _ = CopyFromTerminalAsync(e.Text));
|
||||
|
||||
/// <summary>
|
||||
/// A right-click with nothing selected: reads the clipboard and hands it to the pane that asked.
|
||||
/// </summary>
|
||||
/// <remarks>Marshalled for the reasons <see cref="OnClipboardCopyRequested"/> gives.</remarks>
|
||||
private void OnClipboardPasteRequested(object? sender, TerminalClipboardPasteEventArgs e) =>
|
||||
post(() => _ = PasteIntoTerminalAsync(e.SessionId));
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Silence where there is no clipboard, and that is a different answer from the keychain's COPY buttons
|
||||
/// — which say so, because a button that appears to do nothing is worse than a refusal. There is nowhere
|
||||
/// to say it from here: the shell's status line is drawn on the enroll, sign-in and unlock cards, none
|
||||
/// of which is on screen while somebody is right-clicking a terminal. Inventing a channel for a branch
|
||||
/// no shipped head reaches would be the wrong trade, and both heads wire a clipboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A throw is swallowed rather than left to become an unobserved task exception. A clipboard is a shared
|
||||
/// machine resource — on Windows another process holding it open is an ordinary, transient failure — and
|
||||
/// a copy that did not happen is worth exactly one more right-click.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task CopyFromTerminalAsync(string text)
|
||||
{
|
||||
if (copyToClipboard is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await copyToClipboard(text).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Deliberately silent; see the remark for both halves of why. Nothing is left half done — the
|
||||
// clipboard either took the text or did not.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An empty clipboard ends here rather than at the renderer. Sending it would bracket nothing and hand
|
||||
/// the remote an empty paste, which is a keystroke nobody made; "there was nothing to paste" is a fact
|
||||
/// about the machine rather than about this terminal, and the honest answer to it is that the
|
||||
/// right-click did nothing — which is what every other terminal does with an empty clipboard too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The answer for a session whose shell has ended is discarded for the reason
|
||||
/// <see cref="TerminalWorkspace.SendClipboardAsync"/> gives for refusing it: nothing visible would
|
||||
/// happen either way, and there is nowhere on this screen to say so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A throw is swallowed, as in <see cref="CopyFromTerminalAsync"/> and for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task PasteIntoTerminalAsync(uint sessionId)
|
||||
{
|
||||
if (readClipboard is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var text = await readClipboard().ConfigureAwait(true);
|
||||
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await workspace
|
||||
.SendClipboardAsync(sessionId, text, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Deliberately silent; see the remark. The read and the send are both inside it, because
|
||||
// whichever of the two failed leaves the same nothing on screen.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
|
||||
@@ -3203,6 +3320,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||
workspace.ClipboardCopyRequested -= OnClipboardCopyRequested;
|
||||
workspace.ClipboardPasteRequested -= OnClipboardPasteRequested;
|
||||
workspace.RendererReattached -= OnRendererReattached;
|
||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||
|
||||
|
||||
Reference in new issue
Block a user