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
@@ -33,11 +33,14 @@ const SERVER_SESSION_ACTIVATED = 4;
|
||||
const SERVER_SESSION_REMOVED = 5;
|
||||
const SERVER_PASTE = 6;
|
||||
const SERVER_FONT_SIZE = 7;
|
||||
const SERVER_CLIPBOARD = 8;
|
||||
|
||||
const CLIENT_INPUT = 1;
|
||||
const CLIENT_ACKNOWLEDGE = 2;
|
||||
const CLIENT_RESIZE = 3;
|
||||
const CLIENT_FONT_SIZE_STEP = 4;
|
||||
const CLIENT_CLIPBOARD_COPY = 5;
|
||||
const CLIENT_CLIPBOARD_PASTE = 6;
|
||||
|
||||
const HEADER_LENGTH = 5;
|
||||
const SCROLLBACK_LINES = 5000;
|
||||
@@ -279,6 +282,66 @@ function handleKey(event) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
── THE RIGHT-CLICK IS A COPY OR A PASTE, AND NEVER A MENU ───────────────────────────────────────────
|
||||
|
||||
What a terminal's right button has meant since the Windows console got QuickEdit and since PuTTY
|
||||
shipped: with something selected it copies and drops the selection, with nothing selected it pastes.
|
||||
No menu, no second click. This page used to answer with the WebView's own context menu — xterm stuffs
|
||||
its helper textarea with the selection on contextmenu precisely so that the browser's Copy entry
|
||||
works — which is correct behaviour for a web page and wrong for a terminal.
|
||||
|
||||
◆ 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 the font-size chords do — see requestFontSizeStep. Writing would have
|
||||
worked from here on Windows, and does not go that way either: 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.
|
||||
|
||||
◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.
|
||||
|
||||
preventDefault alone would suppress the menu and still let xterm's own contextmenu handler run, which
|
||||
moves the 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; with no menu they are an invisible textarea
|
||||
floating over the grid and a DOM selection nothing will clear. Taking the event in the capture phase
|
||||
and stopping it there is what keeps the gesture wholly ours.
|
||||
|
||||
rightClickSelectsWord is off for the same reason from the other side — see createSession.
|
||||
*/
|
||||
function handleRightClick(event, sessionId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = session.term.getSelection();
|
||||
|
||||
if (selection.length > 0) {
|
||||
send(CLIENT_CLIPBOARD_COPY, sessionId, new TextEncoder().encode(selection));
|
||||
|
||||
/*
|
||||
And the selection goes, which is the console's behaviour and is load-bearing rather than cosmetic.
|
||||
It is the only acknowledgement there is that the copy happened — nothing else on screen moves — and
|
||||
it is what makes the *next* right-click a paste. Left standing, a selection somebody had forgotten
|
||||
about would turn every later right-click into a copy of it, and the paste would look broken.
|
||||
*/
|
||||
session.term.clearSelection();
|
||||
} else {
|
||||
send(CLIENT_CLIPBOARD_PASTE, sessionId);
|
||||
}
|
||||
|
||||
// Right-clicking a pane is a way of arriving at it. The caret follows, for the reason the paste frame's
|
||||
// own focus() call gives.
|
||||
session.term.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a pane for a session, or returns the one already there.
|
||||
*
|
||||
@@ -302,6 +365,19 @@ function createSession(sessionId) {
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
fontSize,
|
||||
|
||||
/*
|
||||
Off, and on macOS that is a change from the default rather than a restatement of it: xterm defaults
|
||||
this to true there, so a right-click would select the word under the pointer. With the gesture
|
||||
meaning copy-or-paste, a right-click that quietly makes a selection is a right-click that copies a
|
||||
word instead of pasting — and only on one platform, which is the worst way for it to be wrong.
|
||||
|
||||
Belt and braces today, because handleRightClick takes the event in the capture phase and xterm never
|
||||
sees it. It is here for the upgrade that moves xterm's handler to mousedown, which is where it
|
||||
already is on the Firefox path.
|
||||
*/
|
||||
rightClickSelectsWord: false,
|
||||
|
||||
scrollback: SCROLLBACK_LINES,
|
||||
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
|
||||
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
|
||||
@@ -346,6 +422,10 @@ function createSession(sessionId) {
|
||||
|
||||
term.attachCustomKeyEventHandler(handleKey);
|
||||
|
||||
// On the pane rather than on xterm's own element, and in the capture phase, which is what puts this
|
||||
// ahead of xterm's handler on the element inside it. See handleRightClick.
|
||||
pane.addEventListener('contextmenu', (event) => handleRightClick(event, sessionId), true);
|
||||
|
||||
term.onData((data) => {
|
||||
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
||||
});
|
||||
@@ -562,6 +642,35 @@ function handleFrame(buffer) {
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_CLIPBOARD: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session || payload.length < 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
paste() unconditionally, and that is the whole difference from SERVER_PASTE above. This text came
|
||||
off the system clipboard, which is the case bracketed paste is *for*: readline uses the markers to
|
||||
decide it has been pasted into, and a shell that gets a multi-line command inside them treats every
|
||||
newline as text rather than as "run this". The reasoning that makes a single-line *snippet* worth
|
||||
typing instead — bash marking a bracketed paste as an active region, so the command sits at the
|
||||
prompt looking selected — is the reasoning for a snippet reading as though it had been typed. A
|
||||
clipboard paste is not pretending to be typing, and every other terminal highlights it too.
|
||||
|
||||
No execute flag, either: a paste inserts and stops. Pressing Enter for somebody is what the
|
||||
sidebar's RUN does, and it is a thing they asked for by name.
|
||||
*/
|
||||
session.term.paste(new TextDecoder().decode(payload));
|
||||
|
||||
// The caret goes where the text landed, for the reason SERVER_PASTE's own focus() call gives — half
|
||||
// of it, anyway. Here the other half is already right: the gesture that asked for this was a click
|
||||
// on the pane, so the host's window never took Win32 focus away.
|
||||
session.term.focus();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_FONT_SIZE: {
|
||||
if (payload.length < 1) {
|
||||
break;
|
||||
@@ -808,4 +917,15 @@ document.addEventListener('visibilitychange', () => {
|
||||
window.addEventListener('focus', reconnectIfDown);
|
||||
window.addEventListener('online', reconnectIfDown);
|
||||
|
||||
/*
|
||||
And nowhere on this page has a context menu, which the panes' own handler nearly finishes on its own: they
|
||||
are positioned at inset 0, so every right-click over a terminal lands on one and is stopped there before it
|
||||
reaches here.
|
||||
|
||||
What is left is the status banner, which is drawn over the bottom of the pane while it has something to say
|
||||
— so a right-click on "Reconnecting the terminal view…" would have been the one place in a terminal window
|
||||
that still opened a browser menu. Prevented and not stopped: there is nothing below this to stop it from.
|
||||
*/
|
||||
document.addEventListener('contextmenu', (event) => event.preventDefault());
|
||||
|
||||
connect();
|
||||
Reference in new issue
Block a user